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}" />