diff --git a/gclo.Engine.Tests/AppSettingsTests.cs b/gclo.Engine.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..b755d1c --- /dev/null +++ b/gclo.Engine.Tests/AppSettingsTests.cs @@ -0,0 +1,49 @@ +using gclo.ViewModels; + +namespace gclo.Engine.Tests; + +/// Pins defaults and load-time sanitizing of . +public sealed class AppSettingsTests +{ + [Fact] + public void Defaults_SplashOnAtFiveSeconds() + { + var settings = new AppSettings(); + + Assert.True(settings.ShowSplashScreen); + Assert.Equal(5_000, settings.SplashMilliseconds); + } + + [Fact] + public void Save_ClampsSplashDuration() + { + string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir); + try + { + var settings = new AppSettings { SplashMilliseconds = 999_999 }; + settings.Save(); + Assert.Equal(AppSettings.MaxSplashMilliseconds, settings.SplashMilliseconds); + + settings.SplashMilliseconds = 1; + settings.Save(); + Assert.Equal(AppSettings.MinSplashMilliseconds, settings.SplashMilliseconds); + + // Round trip: the persisted values load back as saved. + var loaded = AppSettings.Load(); + Assert.Equal(AppSettings.MinSplashMilliseconds, loaded.SplashMilliseconds); + Assert.True(loaded.ShowSplashScreen); + } + finally + { + Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null); + try + { + Directory.Delete(dir, recursive: true); + } + catch (IOException) + { + } + } + } +} diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 0379e2b..516bb92 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -1150,6 +1150,42 @@ public sealed class WorkspaceViewModelTests MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads }; + [Fact] + public async Task OrgRefresh_AccountSeededOrganization_SurvivesAndResyncsTheView() + { + // Regression: the view's editable ComboBox resets its Text when its + // ItemsSource is mutated, blanking the org an account workspace seeded in + // the constructor. The VM must keep the value across the refresh AND raise + // Organization again after the list mutation so the view resyncs its Text. + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Kof", + Organization = "KofTwentyTwo", + TargetRoot = @"C:\repos\kof", + }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "vault-token-1234567890"); + _orgs.Handler = (_, _) => + Task.FromResult>(["KofTwentyTwo", "acme", "other"]); + + var vm = CreateViewModel(account: account, vault: vault); + bool resyncAfterListArrived = false; + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(WorkspaceViewModel.Organization) + && vm.Organizations.Count > 0) + { + resyncAfterListArrived = true; + } + }; + + await WaitUntilAsync(() => vm.Organizations.Count == 3, "org list refresh"); + + Assert.Equal("KofTwentyTwo", vm.Organization); + Assert.True(resyncAfterListArrived, "Organization must be re-raised after the list mutation"); + } + [Fact] public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken() { diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 3692af1..413c70e 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -15,6 +15,17 @@ public sealed class AppSettings public const int MaxConcurrency = 64; public const int DefaultConcurrency = 8; + public const int MinSplashMilliseconds = 500; + public const int MaxSplashMilliseconds = 30_000; + public const int DefaultSplashMilliseconds = 5_000; + + /// + /// Vault id under which the optional default GitHub token is stored. The token + /// itself lives only in the (Credential Manager) — + /// never in this settings file. + /// + public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"); + /// Folder pre-filled as the sync target. Empty means "no default". public string DefaultTargetFolder { get; set; } = ""; @@ -24,6 +35,12 @@ public sealed class AppSettings /// App theme: "System", "Light", or "Dark". Unknown values fall back to "System". public string Theme { get; set; } = "System"; + /// Whether the branded splash overlay shows at startup. + public bool ShowSplashScreen { get; set; } = true; + + /// How long the splash stays up before fading, clamped to 500..30000 ms. + public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds; + private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json"); /// @@ -84,6 +101,7 @@ public sealed class AppSettings // The JSON deserializer can assign null despite the non-nullable annotation. DefaultTargetFolder ??= ""; DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency); + SplashMilliseconds = Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds); if (Theme is not ("System" or "Light" or "Dark")) { Theme = "System"; diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index fff3534..8e5ad61 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -352,11 +352,19 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); lookupToken.ThrowIfCancellationRequested(); + // An editable ComboBox resets its Text when its ItemsSource is mutated, + // and the TwoWay binding would wipe a value that was already set — an + // account workspace seeds Organization in the constructor, and this + // refresh lands ~a second later. Capture, restore, and force a binding + // resync so the seeded (or typed) organization survives the refresh. + string organizationBeforeRefresh = Organization; Organizations.Clear(); foreach (string org in orgs) { Organizations.Add(org); } + Organization = organizationBeforeRefresh; + OnPropertyChanged(nameof(Organization)); // The production lister always lists the token's own account first, so a // single entry means no organizations were visible. StatusText = orgs.Count <= 1 diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml index 869b78c..4765f19 100644 --- a/gclo/AboutDialog.xaml +++ b/gclo/AboutDialog.xaml @@ -10,20 +10,50 @@ CloseButtonText="Close" DefaultButton="Close"> - + - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -56,6 +58,7 @@ x:Name="TokenBox" AutomationProperties.AutomationId="WizardTokenBox" Header="Personal access token" + ToolTipService.ToolTip="Stored in Windows Credential Manager, never in a file; needs repository read access" PlaceholderText="ghp_…" PasswordChanged="TokenBox_PasswordChanged" /> @@ -121,6 +126,7 @@ Grid.Column="0" AutomationProperties.AutomationId="WizardTargetBox" Header="Target folder" + ToolTipService.ToolTip="Local folder each repository is cloned into" PlaceholderText="C:\src\my-org" Text="{x:Bind ViewModel.TargetRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />