Skip to content

Commit 1d2f4d2

Browse files
dooly123claude
andcommitted
Add cache-drift detection to the Develop tab; reskin Expander for glass
CacheDriftService finds git packages whose files were edited in place under Library/PackageCache (invisible to git, and lost on the next re-resolve). For each it clones the pristine source at the cached revision, overlays the cache's current files, and diffs — leaving a ready-to-PR work clone so the change can be contributed upstream. Surfaced on the Develop tab with a scan + open-PR flow, backed by new GitService helpers (CloneAtAsync / SetConfigAsync / AddAllAsync). Also reskins the Fluent Expander (transparent header, muted chevron) to match the glass cards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8f42393 commit 1d2f4d2

7 files changed

Lines changed: 371 additions & 4 deletions

File tree

src/BasisPM.App/Localization/Languages/en.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,10 @@
890890
"key": "develop.button.installGit",
891891
"value": "Install Git"
892892
},
893+
{
894+
"key": "develop.button.mountAll",
895+
"value": "Mount all"
896+
},
893897
{
894898
"key": "develop.button.mountToEdit",
895899
"value": "Mount to edit"
@@ -906,6 +910,14 @@
906910
"key": "develop.button.refresh",
907911
"value": "Refresh"
908912
},
913+
{
914+
"key": "develop.button.reviewOpenPr",
915+
"value": "Review & open PR"
916+
},
917+
{
918+
"key": "develop.button.scanCache",
919+
"value": "Scan for changes"
920+
},
909921
{
910922
"key": "develop.button.signIn",
911923
"value": "Sign in"
@@ -918,6 +930,34 @@
918930
"key": "develop.button.swapBack",
919931
"value": "Swap back"
920932
},
933+
{
934+
"key": "develop.section.cacheChanges",
935+
"value": "Accidental cache changes"
936+
},
937+
{
938+
"key": "develop.section.cacheChangesHint",
939+
"value": "Unity keeps git packages in Library/PackageCache. If one was edited there by accident, scan to detect it and open a pull request upstream."
940+
},
941+
{
942+
"key": "develop.section.noCacheChanges",
943+
"value": "No local changes found in cached packages."
944+
},
945+
{
946+
"key": "develop.status.scanningDrift",
947+
"value": "Scanning cached packages for local changes…"
948+
},
949+
{
950+
"key": "develop.status.noDrift",
951+
"value": "No local changes in cached packages."
952+
},
953+
{
954+
"key": "develop.status.driftFound",
955+
"value": "Found local changes in {0} cached package(s)."
956+
},
957+
{
958+
"key": "develop.status.driftError",
959+
"value": "Couldn't scan cached packages: {0}"
960+
},
921961
{
922962
"key": "develop.confirm.alreadyMounted",
923963
"value": "{0} is already mounted at Packages/{0}.\n\nReplace it with a fresh clone? Any local edits in that folder will be lost."
@@ -1002,6 +1042,10 @@
10021042
"key": "develop.status.mounted",
10031043
"value": "Mounted {0} into Packages/ — edit it in Unity, then Submit PR."
10041044
},
1045+
{
1046+
"key": "develop.status.mountedAll",
1047+
"value": "Mounted {0} package(s); skipped {1}; {2} failed."
1048+
},
10051049
{
10061050
"key": "develop.status.mounting",
10071051
"value": "Mounting {0}…"

src/BasisPM.App/Styles/BasisTheme.axaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,33 @@
8080
<GradientStop Offset="0" Color="#FF2C50"/>
8181
<GradientStop Offset="1" Color="#A855F7"/>
8282
</LinearGradientBrush>
83+
84+
<!-- Expander reskin: the Fluent defaults (opaque header surface, blue-grey chevron)
85+
clash with the glass theme. Override its theme resources to match the cards. -->
86+
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="Transparent"/>
87+
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="{StaticResource BasisSurfaceHover}"/>
88+
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="{StaticResource BasisSurfaceHover}"/>
89+
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="Transparent"/>
90+
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="{StaticResource BasisTextPrimary}"/>
91+
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="{StaticResource BasisTextPrimary}"/>
92+
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="{StaticResource BasisTextPrimary}"/>
93+
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="{StaticResource BasisTextDim}"/>
94+
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="Transparent"/>
95+
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="Transparent"/>
96+
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="Transparent"/>
97+
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="Transparent"/>
98+
<SolidColorBrush x:Key="ExpanderContentBackground" Color="Transparent"/>
99+
<SolidColorBrush x:Key="ExpanderContentBorderBrush" Color="{StaticResource BasisBorder}"/>
100+
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="{StaticResource BasisTextMuted}"/>
101+
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="{StaticResource BasisBrand}"/>
102+
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="{StaticResource BasisBrand}"/>
103+
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="{StaticResource BasisTextDim}"/>
104+
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent"/>
105+
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="Transparent"/>
106+
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="Transparent"/>
107+
<SolidColorBrush x:Key="ExpanderChevronBorderBrush" Color="Transparent"/>
108+
<SolidColorBrush x:Key="ExpanderChevronBorderBrushPointerOver" Color="Transparent"/>
109+
<SolidColorBrush x:Key="ExpanderChevronBorderBrushPressed" Color="Transparent"/>
83110
</ResourceDictionary>
84111
</Styles.Resources>
85112

@@ -341,4 +368,20 @@
341368
<Setter Property="Background" Value="#33FFFFFF"/>
342369
<Setter Property="TextElement.Foreground" Value="#FFFFFF"/>
343370
</Style>
371+
372+
<!-- Expander: a frosted-glass collapsible section that matches Border.card -->
373+
<Style Selector="Expander">
374+
<Setter Property="Background" Value="{StaticResource BasisGlassBrush}"/>
375+
<Setter Property="BorderBrush" Value="{StaticResource BasisBorderBrush}"/>
376+
<Setter Property="BorderThickness" Value="1"/>
377+
<Setter Property="CornerRadius" Value="14"/>
378+
<Setter Property="Foreground" Value="{StaticResource BasisTextPrimaryBrush}"/>
379+
</Style>
380+
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
381+
<Setter Property="Padding" Value="20,14"/>
382+
<Setter Property="MinHeight" Value="0"/>
383+
</Style>
384+
<Style Selector="Expander /template/ Border#ExpanderContent">
385+
<Setter Property="Padding" Value="20,6,20,18"/>
386+
</Style>
344387
</Styles>

src/BasisPM.App/ViewModels/DevelopViewModel.cs

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public sealed class DevelopViewModel : ObservableObject
1212
{
1313
private readonly MountService _mount;
1414
private readonly ContributeService _contribute;
15+
private readonly CacheDriftService _drift;
1516
private readonly GitHubAuthService _auth;
1617
private readonly GitHubApiService _api;
1718
private readonly GitService _git;
@@ -26,6 +27,7 @@ public sealed class DevelopViewModel : ObservableObject
2627

2728
public ObservableCollection<GitPackageRow> GitPackages { get; } = new();
2829
public ObservableCollection<MountedRow> Mounted { get; } = new();
30+
public ObservableCollection<CacheDriftRow> DriftPackages { get; } = new();
2931

3032
public string InstallName => _install?.DisplayName ?? L.Tr("develop.install.none");
3133
public bool HasInstall => _install is not null && _install.HasUnityProject;
@@ -35,6 +37,11 @@ public sealed class DevelopViewModel : ObservableObject
3537
public bool NoGitPackages => HasInstall && GitPackages.Count == 0;
3638
public bool HasMounted => Mounted.Count > 0;
3739

40+
private bool _driftScanned;
41+
public bool DriftScanned { get => _driftScanned; private set { if (SetField(ref _driftScanned, value)) OnPropertyChanged(nameof(NoDrift)); } }
42+
public bool HasDrift => DriftPackages.Count > 0;
43+
public bool NoDrift => DriftScanned && DriftPackages.Count == 0;
44+
3845
public bool GitAvailable => _git.IsAvailable;
3946
public bool GitMissing => !_git.IsAvailable;
4047
public bool GhAvailable => _auth.GhAvailable;
@@ -58,23 +65,29 @@ public bool AuthOk
5865
public RelayCommand RecheckCommand { get; }
5966
public RelayCommand ApplyPatCommand { get; }
6067
public RelayCommand<GitPackageRow> MountCommand { get; }
68+
public RelayCommand MountAllCommand { get; }
6169
public RelayCommand<MountedRow> SubmitPrCommand { get; }
6270
public RelayCommand<MountedRow> SwapBackCommand { get; }
6371
public RelayCommand<MountedRow> OpenInUnityCommand { get; }
72+
public RelayCommand ScanDriftCommand { get; }
73+
public RelayCommand<CacheDriftRow> OpenDriftPrCommand { get; }
6474

65-
public DevelopViewModel(MountService mount, ContributeService contribute, GitHubAuthService auth,
75+
public DevelopViewModel(MountService mount, ContributeService contribute, CacheDriftService drift, GitHubAuthService auth,
6676
GitHubApiService api, GitService git, MountRegistry registry, MainWindowViewModel shell)
6777
{
68-
_mount = mount; _contribute = contribute; _auth = auth; _api = api; _git = git; _registry = registry; _shell = shell;
78+
_mount = mount; _contribute = contribute; _drift = drift; _auth = auth; _api = api; _git = git; _registry = registry; _shell = shell;
6979

7080
RefreshCommand = new RelayCommand(RefreshAsync);
7181
InstallGitCommand = new RelayCommand(() => OpenUrl("https://git-scm.com/downloads"));
7282
RecheckCommand = new RelayCommand(RefreshAsync);
7383
ApplyPatCommand = new RelayCommand(ApplyPatAsync);
7484
MountCommand = new RelayCommand<GitPackageRow>(MountAsync);
85+
MountAllCommand = new RelayCommand(MountAllAsync);
7586
SubmitPrCommand = new RelayCommand<MountedRow>(SubmitPrAsync);
7687
SwapBackCommand = new RelayCommand<MountedRow>(SwapBackAsync);
7788
OpenInUnityCommand = new RelayCommand<MountedRow>(row => { if (_install is not null) _ = _shell.OpenProjectInUnityAsync(_install); });
89+
ScanDriftCommand = new RelayCommand(ScanDriftAsync);
90+
OpenDriftPrCommand = new RelayCommand<CacheDriftRow>(OpenDriftPrAsync);
7891
}
7992

8093
public void SetActiveInstall(BasisInstall install)
@@ -99,6 +112,8 @@ private void Refresh()
99112

100113
GitPackages.Clear();
101114
Mounted.Clear();
115+
DriftPackages.Clear();
116+
DriftScanned = false;
102117

103118
if (_install is not null && _install.HasUnityProject)
104119
{
@@ -119,6 +134,8 @@ private void Refresh()
119134
OnPropertyChanged(nameof(HasGitPackages));
120135
OnPropertyChanged(nameof(NoGitPackages));
121136
OnPropertyChanged(nameof(HasMounted));
137+
OnPropertyChanged(nameof(HasDrift));
138+
OnPropertyChanged(nameof(NoDrift));
122139
}
123140

124141
private async Task CheckAuthAsync()
@@ -176,6 +193,97 @@ private async Task MountAsync(GitPackageRow? row)
176193
finally { IsBusy = false; }
177194
}
178195

196+
// Mount every git package that isn't mounted yet, skipping any whose folder is already present.
197+
private async Task MountAllAsync()
198+
{
199+
if (_install is null) return;
200+
if (!_git.IsAvailable) { _shell.SetStatus(L.Tr("develop.status.gitRequiredMount"), StatusKind.Error); return; }
201+
202+
var rows = GitPackages.ToList();
203+
if (rows.Count == 0) return;
204+
205+
IsBusy = true;
206+
int ok = 0, skipped = 0, failed = 0;
207+
try
208+
{
209+
foreach (var row in rows)
210+
{
211+
_shell.SetStatus(L.Tr("develop.status.mounting", row.Id));
212+
var result = await _mount.MountAsync(_install, row.Id, row.GitUrl, line => Dispatcher.UIThread.Post(() => _shell.SetStatus(line)));
213+
if (result.Ok) ok++;
214+
else if (result.Error is not null && result.Error.Contains("already exists", StringComparison.OrdinalIgnoreCase)) skipped++;
215+
else failed++;
216+
}
217+
Refresh();
218+
_shell.SetStatus(L.Tr("develop.status.mountedAll", ok, skipped, failed), failed > 0 ? StatusKind.Error : StatusKind.Success);
219+
}
220+
catch (Exception ex) { _shell.SetStatus(L.Tr("develop.status.mountError", ex.Message), StatusKind.Error); }
221+
finally { IsBusy = false; }
222+
}
223+
224+
// Scan the project's git packages for accidental edits made directly in Library/PackageCache.
225+
private async Task ScanDriftAsync()
226+
{
227+
if (_install is null) return;
228+
if (!_git.IsAvailable) { _shell.SetStatus(L.Tr("develop.status.gitRequiredMount"), StatusKind.Error); return; }
229+
230+
IsBusy = true;
231+
DriftPackages.Clear();
232+
OnPropertyChanged(nameof(HasDrift));
233+
_shell.SetStatus(L.Tr("develop.status.scanningDrift"));
234+
try
235+
{
236+
var drifts = await _drift.ScanAsync(_install, line => Dispatcher.UIThread.Post(() => _shell.SetStatus(line)));
237+
foreach (var d in drifts)
238+
{
239+
var summary = string.Join(", ", d.ChangedFiles.Take(6));
240+
if (d.ChangedFiles.Count > 6) summary += ", …";
241+
DriftPackages.Add(new CacheDriftRow(d.PackageId, d.WorkClonePath, d.GitUrl, d.ChangedFiles.Count, summary));
242+
}
243+
DriftScanned = true;
244+
OnPropertyChanged(nameof(HasDrift));
245+
OnPropertyChanged(nameof(NoDrift));
246+
_shell.SetStatus(
247+
drifts.Count == 0 ? L.Tr("develop.status.noDrift") : L.Tr("develop.status.driftFound", drifts.Count),
248+
drifts.Count == 0 ? StatusKind.Success : StatusKind.Info);
249+
}
250+
catch (Exception ex) { _shell.SetStatus(L.Tr("develop.status.driftError", ex.Message), StatusKind.Error); }
251+
finally { IsBusy = false; }
252+
}
253+
254+
// Turn an accidental cache edit into a pull request, reusing the mounted-package PR flow on the work clone.
255+
private async Task OpenDriftPrAsync(CacheDriftRow? row)
256+
{
257+
if (row is null) return;
258+
if (!_git.IsAvailable) { _shell.SetStatus(L.Tr("develop.status.gitRequired"), StatusKind.Error); return; }
259+
260+
var token = await _auth.GetTokenAsync();
261+
if (string.IsNullOrEmpty(token)) { _shell.SetStatus(L.Tr("develop.status.signInFirst"), StatusKind.Error); return; }
262+
var user = await _api.GetUserAsync(token);
263+
if (user is null) { _shell.SetStatus(L.Tr("develop.status.loginUnverified"), StatusKind.Error); return; }
264+
265+
var draft = await Dialogs.SubmitPrAsync(row.PackageId);
266+
if (draft is null) return;
267+
268+
IsBusy = true;
269+
_shell.SetStatus(L.Tr("develop.status.submittingPr", row.PackageId));
270+
try
271+
{
272+
var result = await _contribute.SubmitPrAsync(row.WorkClonePath, token, user, draft, line => Dispatcher.UIThread.Post(() => _shell.SetStatus(line)));
273+
if (result.Ok)
274+
{
275+
_shell.SetStatus(L.Tr("develop.status.prOpened", result.Forked ? L.Tr("develop.status.viaFork") : "", result.PrUrl), StatusKind.Success);
276+
if (!string.IsNullOrEmpty(result.PrUrl)) OpenUrl(result.PrUrl!);
277+
DriftPackages.Remove(row);
278+
OnPropertyChanged(nameof(HasDrift));
279+
OnPropertyChanged(nameof(NoDrift));
280+
}
281+
else _shell.SetStatus(result.Error ?? L.Tr("develop.status.prFailed"), StatusKind.Error);
282+
}
283+
catch (Exception ex) { _shell.SetStatus(L.Tr("develop.status.prError", ex.Message), StatusKind.Error); }
284+
finally { IsBusy = false; }
285+
}
286+
179287
private async Task SwapBackAsync(MountedRow? row)
180288
{
181289
if (row is null || _install is null) return;
@@ -237,3 +345,5 @@ private static void TryForceDelete(string path)
237345
public sealed record GitPackageRow(string Id, string GitUrl, string Host, string Slug, bool InSubfolder);
238346

239347
public sealed record MountedRow(string Id, string FolderPath, string OriginalManifestValue);
348+
349+
public sealed record CacheDriftRow(string PackageId, string WorkClonePath, string GitUrl, int ChangedCount, string ChangedSummary);

src/BasisPM.App/ViewModels/MainWindowViewModel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ public MainWindowViewModel()
7373
FundingVM = new FundingViewModel();
7474
var mountService = new MountService(_gitService, _projectService, _mountRegistry);
7575
var contributeService = new ContributeService(_gitService, _ghApi);
76-
DevelopVM = new DevelopViewModel(mountService, contributeService, _ghAuth, _ghApi, _gitService, _mountRegistry, this);
76+
var cacheDriftService = new CacheDriftService(_gitService);
77+
DevelopVM = new DevelopViewModel(mountService, contributeService, cacheDriftService, _ghAuth, _ghApi, _gitService, _mountRegistry, this);
7778
AnnouncementsVM = new AnnouncementsViewModel(_announcementService);
7879
DocumentationVM = new DocumentationViewModel();
7980
CommunityVM = new CommunityViewModel(AnnouncementsVM, DocumentationVM, FundingVM);

src/BasisPM.App/Views/DevelopView.axaml

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@
6868
<Border Classes="card" Padding="0">
6969
<StackPanel>
7070
<Border Padding="20,16" BorderBrush="{StaticResource BasisBorderBrush}" BorderThickness="0,0,0,1">
71-
<TextBlock Text="{loc:Tr develop.section.gitPackages}" Classes="h3"/>
71+
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
72+
<TextBlock Grid.Column="0" Text="{loc:Tr develop.section.gitPackages}" Classes="h3" VerticalAlignment="Center"/>
73+
<Button Grid.Column="1" Classes="primary" Content="{loc:Tr develop.button.mountAll}"
74+
Command="{Binding MountAllCommand}" IsVisible="{Binding HasGitPackages}"/>
75+
</Grid>
7276
</Border>
7377
<ItemsControl ItemsSource="{Binding GitPackages}">
7478
<ItemsControl.ItemTemplate>
@@ -92,6 +96,40 @@
9296
</StackPanel>
9397
</Border>
9498

99+
<!-- Accidental edits in Library/PackageCache -->
100+
<Border Classes="card" Padding="0">
101+
<StackPanel>
102+
<Border Padding="20,16" BorderBrush="{StaticResource BasisBorderBrush}" BorderThickness="0,0,0,1">
103+
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
104+
<StackPanel Grid.Column="0" Spacing="3">
105+
<TextBlock Text="{loc:Tr develop.section.cacheChanges}" Classes="h3"/>
106+
<TextBlock Text="{loc:Tr develop.section.cacheChangesHint}" Classes="muted" FontSize="11" TextWrapping="Wrap"/>
107+
</StackPanel>
108+
<Button Grid.Column="1" Content="{loc:Tr develop.button.scanCache}" Command="{Binding ScanDriftCommand}" VerticalAlignment="Top"/>
109+
</Grid>
110+
</Border>
111+
<ItemsControl ItemsSource="{Binding DriftPackages}">
112+
<ItemsControl.ItemTemplate>
113+
<DataTemplate DataType="vm:CacheDriftRow">
114+
<Border Padding="20,14" BorderBrush="{StaticResource BasisBorderBrush}" BorderThickness="0,0,0,1">
115+
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
116+
<StackPanel Grid.Column="0" Spacing="3" ClipToBounds="True">
117+
<TextBlock Text="{Binding PackageId}" FontWeight="SemiBold"/>
118+
<TextBlock Text="{Binding ChangedSummary}" Classes="dim" FontSize="11" TextTrimming="CharacterEllipsis"/>
119+
</StackPanel>
120+
<Button Grid.Column="1" Classes="primary" Content="{loc:Tr develop.button.reviewOpenPr}"
121+
Command="{Binding #Root.((vm:DevelopViewModel)DataContext).OpenDriftPrCommand}"
122+
CommandParameter="{Binding}"/>
123+
</Grid>
124+
</Border>
125+
</DataTemplate>
126+
</ItemsControl.ItemTemplate>
127+
</ItemsControl>
128+
<TextBlock Text="{loc:Tr develop.section.noCacheChanges}" Classes="muted" FontSize="12"
129+
Margin="20,16" IsVisible="{Binding NoDrift}"/>
130+
</StackPanel>
131+
</Border>
132+
95133
<!-- Mounted for editing -->
96134
<Border Classes="card" Padding="0" IsVisible="{Binding HasMounted}">
97135
<StackPanel>

0 commit comments

Comments
 (0)