diff --git a/docs/plans/default-token-ux.md b/docs/plans/default-token-ux.md index 066c8d9..b443bfb 100644 --- a/docs/plans/default-token-ux.md +++ b/docs/plans/default-token-ux.md @@ -46,9 +46,12 @@ | User cleared the box, or pressed Remove | empty | "The default token will be removed on Save. *N* account(s) use it and will stop syncing until a new one is saved." (plain "…will be removed on Save." when N = 0) | hidden | Save semantics: mask unchanged → keep; non-empty and not the mask → store; empty -→ remove (only if one was saved). Removing while `Default` accounts exist shows a -confirmation ("Remove the default token? These accounts use it: A, B.") before the -dialog closes; Cancel returns to the dialog with the mask restored. +→ remove (only if one was saved). Removing while `Default` accounts exist is +confirmed **inline** rather than with a second dialog (a `ContentDialog` cannot +open another one, and the dialog guard allows one at a time): the caption turns +critical, names the accounts ("2 accounts use it and will stop syncing until a new +one is saved: A, B."), and Save is the confirmation; typing a token or Cancel +backs out. Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the mask is announced as the field's value by the password pattern (dots), the diff --git a/gclo.Engine.Tests/AccountWizardViewModelTests.cs b/gclo.Engine.Tests/AccountWizardViewModelTests.cs index 4071dbd..a335793 100644 --- a/gclo.Engine.Tests/AccountWizardViewModelTests.cs +++ b/gclo.Engine.Tests/AccountWizardViewModelTests.cs @@ -566,6 +566,24 @@ public sealed class AccountWizardViewModelTests : IDisposable + [Fact] + public async Task EditWizard_OrganizationSurvivesTheStepTwoListRefresh() + { + // The editable ComboBox on step 3 writes "" into Organization when its + // ItemsSource is replaced; the view model must restore the seeded value. + Account existing = MakeAccount("Work") with { Organization = "KofTwentyTwo" }; + _store.Save(existing, "ghp_own"); + AccountWizardViewModel wizard = NewWizard(existing, "ghp_own"); + wizard.Organizations.CollectionChanged += (_, _) => wizard.Organization = ""; + _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo", "other"]); + + await AdvanceToStepAsync(wizard, 3); + + Assert.Equal("KofTwentyTwo", wizard.Organization); + } + + + [Fact] public void DefaultTokenCaption_CountsOtherAccounts() { diff --git a/gclo.Engine.Tests/SettingsTokenStateTests.cs b/gclo.Engine.Tests/SettingsTokenStateTests.cs new file mode 100644 index 0000000..3fcee69 --- /dev/null +++ b/gclo.Engine.Tests/SettingsTokenStateTests.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +/// +/// : the mask stands for a saved token, Save's action +/// follows from what the box holds, and the captions name the consequence (#101). +/// +public sealed class SettingsTokenStateTests +{ + private static SettingsTokenState Saved(params string[] dependents) => new(hasSavedToken: true, dependents); + + private static SettingsTokenState Empty() => new(hasSavedToken: false, []); + + + + [Fact] + public void NullDependents_Throws() + => Assert.Throws(() => new SettingsTokenState(true, null!)); + + + + [Fact] + public void InitialBoxValue_IsTheMaskOnlyWhenATokenIsSaved() + { + Assert.Equal(SettingsTokenState.Mask, Saved().InitialBoxValue); + Assert.Equal("", Empty().InitialBoxValue); + Assert.DoesNotContain("ghp", SettingsTokenState.Mask); + } + + + + [Theory] + [InlineData(true, SettingsTokenState.Mask, SettingsTokenAction.Keep)] + [InlineData(true, "ghp_new", SettingsTokenAction.Store)] + [InlineData(true, "", SettingsTokenAction.Remove)] + [InlineData(false, "", SettingsTokenAction.Keep)] + [InlineData(false, "ghp_new", SettingsTokenAction.Store)] + [InlineData(false, SettingsTokenState.Mask, SettingsTokenAction.Store)] + public void Plan_FollowsFromTheBox(bool hasSaved, string box, SettingsTokenAction expected) + { + var state = new SettingsTokenState(hasSaved, []); + + Assert.Equal(expected, state.Plan(box)); + } + + + + [Fact] + public void Plan_NullBox_Throws() + => Assert.Throws(() => Saved().Plan(null!)); + + + + [Fact] + public void CanOfferRemoval_OnlyWhileTheMaskIsUntouched() + { + Assert.True(Saved().CanOfferRemoval(SettingsTokenState.Mask)); + Assert.False(Saved().CanOfferRemoval("ghp_new")); + Assert.False(Saved().CanOfferRemoval("")); + Assert.False(Empty().CanOfferRemoval(SettingsTokenState.Mask)); + } + + + + [Fact] + public void Caption_SavedAndUntouched_SaysWhoUsesIt() + { + Assert.Equal( + "A default token is saved and used by Quick Sync. Type to replace it; clear the box to remove it.", + Saved().Caption(SettingsTokenState.Mask)); + Assert.Contains("Quick Sync and 1 account.", Saved("Work").Caption(SettingsTokenState.Mask)); + Assert.Contains("Quick Sync and 2 accounts.", Saved("Work", "Home").Caption(SettingsTokenState.Mask)); + } + + + + [Fact] + public void Caption_NothingSaved_SaysSo() + { + Assert.StartsWith("No default token saved.", Empty().Caption(""), StringComparison.Ordinal); + Assert.StartsWith("The default token will be saved on Save", Empty().Caption("ghp_new"), StringComparison.Ordinal); + } + + + + [Fact] + public void Caption_Replacing_MentionsTheAccountsThatRotateWithIt() + { + Assert.Equal("The default token will be replaced on Save.", Saved().Caption("ghp_new")); + Assert.Contains("The 1 account that uses it switches", Saved("Work").Caption("ghp_new")); + Assert.Contains("The 2 accounts that use it switch", Saved("Work", "Home").Caption("ghp_new")); + } + + + + [Fact] + public void Caption_Removing_WarnsAndNamesTheDependentAccounts() + { + Assert.Equal("The default token will be removed on Save.", Saved().Caption("")); + Assert.False(Saved().RemovalBreaksAccounts("")); + + string one = Saved("Work").Caption(""); + Assert.Contains("1 account uses it and will stop syncing", one); + Assert.Contains("Work", one); + Assert.True(Saved("Work").RemovalBreaksAccounts("")); + + string two = Saved("Work", "Home").Caption(""); + Assert.Contains("2 accounts use it and will stop syncing", two); + Assert.Contains("Work, Home", two); + } + + + + [Fact] + public void RemovalBreaksAccounts_IsFalseUnlessSaveWouldRemove() + { + Assert.False(Saved("Work").RemovalBreaksAccounts(SettingsTokenState.Mask)); + Assert.False(Saved("Work").RemovalBreaksAccounts("ghp_new")); + Assert.False(Empty().RemovalBreaksAccounts("")); + } + + + + [Fact] + public void Captions_NeverContainTheMaskOrATokenValue() + { + foreach(string box in new[] { SettingsTokenState.Mask, "ghp_secret", "" }) + { + string caption = Saved("Work").Caption(box); + Assert.DoesNotContain("ghp_secret", caption); + Assert.DoesNotContain(SettingsTokenState.Mask, caption); + } + } +} diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 9c6852d..0f3df54 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -1823,6 +1823,66 @@ public sealed class WorkspaceViewModelTests : IDisposable + // ---------------------------------------------------------------- shown: saved accounts load themselves + + [Fact] + public async Task LoadRepositoriesIfSaved_Account_LoadsOnce_WithTheResolvedToken() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Work", + Organization = "acme", + TargetRoot = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")), + }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "vault-token-1234567890"); + _lister.Repositories = [new RepoDescriptor("alpha", "https://example.test/acme/alpha.git", "main", false)]; + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault); + + Assert.True(vm.LoadRepositoriesIfSaved()); + await WaitUntilAsync(() => vm.HasLoadedRepos, "repositories to load"); + + Assert.Contains(_lister.Calls, c => + string.Equals(c.Organization, "acme", StringComparison.Ordinal) + && string.Equals(c.Token, "vault-token-1234567890", StringComparison.Ordinal)); + Assert.False(vm.LoadRepositoriesIfSaved()); // already loaded: showing it again is a no-op + } + + + + [Fact] + public void LoadRepositoriesIfSaved_QuickSync_DoesNothing() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.Token = "typed-token-1234567890"; + vm.Organization = "acme"; + + Assert.False(vm.LoadRepositoriesIfSaved()); + Assert.Empty(_lister.Calls); + } + + + + [Fact] + public void LoadRepositoriesIfSaved_AccountWithoutAToken_LeavesTheCardUp() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Shared", + Organization = "acme", + TargetRoot = @"C:\repos\shared", + TokenSource = TokenSource.Default, + }; + WorkspaceViewModel vm = CreateViewModel(account: account, vault: new InMemoryVault()); + + Assert.False(vm.LoadRepositoriesIfSaved()); + Assert.Empty(_lister.Calls); + } + + + [Fact] public void AccountConstruction_DefaultTokenAccount_WithoutADefaultToken_StartsEmpty() { diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index 69d71b0..8dff12e 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -363,11 +363,18 @@ public sealed partial class AccountWizardViewModel : ObservableObject try { IReadOnlyList organizations = await _orgLister.ListOrganizationsAsync(candidate); + // An editable ComboBox resets its text when its ItemsSource is + // mutated, and the TwoWay binding writes that empty text back here, + // wiping the organization an edit (or a seed) started with. Capture, + // restore, and re-raise so step 3 shows what the account has. + string organizationBeforeRefresh = Organization; Organizations.Clear(); foreach(string organization in organizations) { Organizations.Add(organization); } + Organization = organizationBeforeRefresh; + OnPropertyChanged(nameof(Organization)); TokenError = ""; _log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible."); } diff --git a/gclo.ViewModels/SettingsTokenAction.cs b/gclo.ViewModels/SettingsTokenAction.cs new file mode 100644 index 0000000..b0cded1 --- /dev/null +++ b/gclo.ViewModels/SettingsTokenAction.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// What Save does with the default-token box (). +public enum SettingsTokenAction +{ + /// Leave the vault as it is: the mask is untouched, or nothing was saved and nothing typed. + Keep = 0, + + /// Store what was typed as the default token (replacing a saved one). + Store = 1, + + /// Delete the saved default token: the box was cleared or Remove was pressed. + Remove = 2, +} diff --git a/gclo.ViewModels/SettingsTokenState.cs b/gclo.ViewModels/SettingsTokenState.cs new file mode 100644 index 0000000..628733c --- /dev/null +++ b/gclo.ViewModels/SettingsTokenState.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// The Settings dialog's default-token box, as state the dialog only renders: what +/// the box shows, what the caption says, and what Save does with what the user left +/// in the box (#101). A saved token is represented by a fixed , +/// never by the token itself, so the box reads as "something is saved" and a reveal +/// button can only ever reveal the mask or what the user typed this session. +/// +public sealed class SettingsTokenState +{ + /// What the box shows while a saved token is untouched. A constant, not the token. + public const string Mask = "••••••••••••••••"; + + private readonly IReadOnlyList _dependentAccounts; + + + + /// + /// : whether the vault holds a default token; + /// : the names of the accounts that resolve + /// to it (), which a removal would break. + /// + public SettingsTokenState(bool hasSavedToken, IReadOnlyList dependentAccounts) + { + ArgumentNullException.ThrowIfNull(dependentAccounts); + HasSavedToken = hasSavedToken; + _dependentAccounts = dependentAccounts; + } + + + + /// True when the vault held a default token when the dialog opened. + public bool HasSavedToken { get; } + + /// How many accounts resolve to the default token. + public int DependentCount => _dependentAccounts.Count; + + /// The initial box value: the mask when a token is saved, empty otherwise. + public string InitialBoxValue => HasSavedToken ? Mask : ""; + + /// True when "Remove saved token" applies: a token is saved and the box still shows the mask. + public bool CanOfferRemoval(string boxValue) => HasSavedToken && string.Equals(boxValue, Mask, StringComparison.Ordinal); + + + + /// What Save will do with , the box's current content. + public SettingsTokenAction Plan(string boxValue) + { + ArgumentNullException.ThrowIfNull(boxValue); + if(string.Equals(boxValue, Mask, StringComparison.Ordinal)) + { + return HasSavedToken ? SettingsTokenAction.Keep : SettingsTokenAction.Store; + } + if(boxValue.Length > 0) + { + return SettingsTokenAction.Store; + } + return HasSavedToken ? SettingsTokenAction.Remove : SettingsTokenAction.Keep; + } + + + + /// The caption under the box for ; names what Save will do. + public string Caption(string boxValue) + { + switch(Plan(boxValue)) + { + case SettingsTokenAction.Store: + return HasSavedToken + ? "The default token will be replaced on Save." + RotationNote() + : "The default token will be saved on Save; Quick Sync uses it on every launch."; + case SettingsTokenAction.Remove: + return "The default token will be removed on Save." + RemovalWarning(); + default: + return HasSavedToken + ? "A default token is saved" + UsedBy() + ". Type to replace it; clear the box to remove it." + : "No default token saved. Quick Sync will ask for a token each launch."; + } + } + + + + /// True when Save would remove a token that accounts depend on; the caption then carries the warning. + public bool RemovalBreaksAccounts(string boxValue) + => Plan(boxValue) == SettingsTokenAction.Remove && DependentCount > 0; + + + + private string UsedBy() => DependentCount switch + { + 0 => " and used by Quick Sync", + 1 => " and used by Quick Sync and 1 account", + _ => $" and used by Quick Sync and {DependentCount} accounts", + }; + + + + private string RotationNote() => DependentCount switch + { + 0 => "", + 1 => " The 1 account that uses it switches to the new token too.", + _ => $" The {DependentCount} accounts that use it switch to the new token too.", + }; + + + + private string RemovalWarning() + { + if(DependentCount == 0) + { + return ""; + } + string names = string.Join(", ", _dependentAccounts); + return DependentCount == 1 + ? $" 1 account uses it and will stop syncing until a new one is saved: {names}." + : $" {DependentCount} accounts use it and will stop syncing until a new one is saved: {names}."; + } +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index 994a40a..a2371dd 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -166,6 +166,26 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable + /// + /// Called by the shell when the workspace is shown. A saved account knows its + /// organization, folder and token, so it loads its repositories straight away + /// instead of landing on the connect card; Quick Sync, a workspace that already + /// loaded, or an account whose token is missing (the card shows why) do nothing. + /// Returns true when a load was started. + /// + public bool LoadRepositoriesIfSaved() + { + if(_account is null || HasLoadedRepos || !LoadReposCommand.CanExecute(null)) + { + return false; + } + _log.Info($"Opening account '{DisplayName}': loading its repositories."); + _ = LoadReposCommand.ExecuteAsync(null); + return true; + } + + + public ObservableCollection Repos { get; } = new(); /// diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml index b4e1015..9319f20 100644 --- a/gclo/AccountWizardDialog.xaml +++ b/gclo/AccountWizardDialog.xaml @@ -121,6 +121,7 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Step 3 of 4: Organization" /> + { + if(string.Equals(e.PropertyName, nameof(AccountWizardViewModel.Step), StringComparison.Ordinal) && ViewModel.Step == 3) + { + // The panel was collapsed, so the ComboBox has no template (and no inner + // TextBox) until it is measured: reapply on its first size change, and + // once more after the dispatcher drains, whichever comes first. + void OnSized(object sender, SizeChangedEventArgs args) + { + OrgBox.SizeChanged -= OnSized; + EditableComboBox.ReapplyText(OrgBox, ViewModel.Organization); + } + OrgBox.SizeChanged += OnSized; + DispatcherQueue.TryEnqueue(() => EditableComboBox.ReapplyText(OrgBox, ViewModel.Organization)); + } + }; } @@ -182,7 +203,23 @@ public sealed partial class AccountWizardDialog : ContentDialog // when editing an account, step 3 would show blank despite the seeded // organization — see EditableComboBox. private void OrgBox_Loaded(object sender, RoutedEventArgs e) - => EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); + { + var combo = (ComboBox)sender; + EditableComboBox.ReapplyText(combo, ViewModel.Organization); + // Step 2's validation replaces the organization list, which resets the + // editable ComboBox's text; the view model keeps Organization, so put it + // back whenever the list changes (an edit otherwise shows step 3 blank). + if(!_organizationsHooked) + { + _organizationsHooked = true; + ViewModel.Organizations.CollectionChanged += (_, _) => EditableComboBox.ReapplyText(combo, ViewModel.Organization); + } + } + + + + /// Whether already follows the organization list. + private bool _organizationsHooked; diff --git a/gclo/ConnectionFormControl.xaml b/gclo/ConnectionFormControl.xaml index 15857d0..8e67f91 100644 --- a/gclo/ConnectionFormControl.xaml +++ b/gclo/ConnectionFormControl.xaml @@ -37,6 +37,7 @@ AutomationProperties.AutomationId="{x:Bind Id('OrgBox')}" HorizontalAlignment="Stretch" Header="Organization or account" + x:Name="OrgBox" ToolTipService.ToolTip="The GitHub organization or user account whose repositories are synced. Populated from the token; typing a name also works." IsEditable="True" ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}" diff --git a/gclo/ConnectionFormControl.xaml.cs b/gclo/ConnectionFormControl.xaml.cs index 02ca338..2f0ca93 100644 --- a/gclo/ConnectionFormControl.xaml.cs +++ b/gclo/ConnectionFormControl.xaml.cs @@ -79,28 +79,35 @@ public sealed partial class ConnectionFormControl : UserControl // Runs on every host load (the card once, the flyout on each open). A token the - // view model already holds (an account's vault token, or the saved default - // token) is deliberately NOT mirrored into the box: the placeholder says what - // is in effect, and an untouched box keeps it. Only typing replaces (#32). + // view model already holds (an account's token, or the saved default token) is + // deliberately NOT mirrored into the box: the box shows the same fixed mask as + // Settings, so it reads as "a token is in effect" (an empty box read as "no + // token", #101), and an untouched mask keeps it. Only typing replaces (#32). private void OnLoaded(object sender, RoutedEventArgs e) { - TokenBox.Password = ""; - TokenBox.PlaceholderText = ViewModel is null || ViewModel.Token.Length == 0 - ? "ghp_…" - : ViewModel.AccountId is null - ? "Using the saved default token — type here to replace it" - : "Using the account's stored token — type here to replace it"; + bool hasToken = ViewModel is not null && ViewModel.Token.Length > 0; + TokenBox.Password = hasToken ? SettingsTokenState.Mask : ""; + TokenBox.PlaceholderText = "ghp_…"; + // The organization placeholder must not ask for a token the box already has. + OrgBox.PlaceholderText = hasToken ? "Choose an organization, or type one" : "Paste a token first, or type an org"; + ToolTipService.SetToolTip(TokenBox, !hasToken + ? "Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs." + : ViewModel!.AccountId is null + ? "Using the saved default token from Settings. Type here to use a different one for this session." + : "Using this account's token. Type here to use a different one for this session."); } // PasswordBox does not support reliable two-way x:Bind on Password; mirror it - // into the view model by hand. + // into the view model by hand. The mask is not a token: while the box shows it, + // the view model keeps the token it already holds. private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) { - if(ViewModel is not null) + string typed = ((PasswordBox)sender).Password; + if(ViewModel is not null && !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal)) { - ViewModel.Token = ((PasswordBox)sender).Password; + ViewModel.Token = typed; } } @@ -114,11 +121,26 @@ public sealed partial class ConnectionFormControl : UserControl if(ViewModel is not null) { EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); + // The organization lookup replaces the list about a second after an + // account workspace opens, and an editable ComboBox drops its text when + // its ItemsSource changes; the view model restores Organization, so put + // it back into the box each time the list changes. + if(!ReferenceEquals(_organizationsHooked, ViewModel)) + { + _organizationsHooked = ViewModel; + ViewModel.Organizations.CollectionChanged += (_, _) => + EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); + } } } + /// The view model whose organization list this control already follows. + private WorkspaceViewModel? _organizationsHooked; + + + private async void BrowseButton_Click(object sender, RoutedEventArgs e) { if(ViewModel is null || WindowHandleProvider is not { } handle) diff --git a/gclo/EditableComboBox.cs b/gclo/EditableComboBox.cs index 1bdf4ce..1d37905 100644 --- a/gclo/EditableComboBox.cs +++ b/gclo/EditableComboBox.cs @@ -27,20 +27,26 @@ internal static class EditableComboBox /// public static void ReapplyText(ComboBox combo, string text) { + // When the value is one of the items, selecting it is the clean path: the + // combo renders it like a user's pick, with no placeholder or dropdown side + // effects (writing text into a focused editable combo opens its list, and + // closing that list throws the text away). Free text still goes to the box. + if(SelectItem(combo, text)) + { + return; + } if(FindInnerTextBox(combo) is TextBox inner) { - inner.Text = text; - inner.SelectionStart = text.Length; + Apply(inner, text); } else { // Template not applied yet: try once more after this layout pass. combo.DispatcherQueue.TryEnqueue(() => { - if(FindInnerTextBox(combo) is TextBox late) + if(!SelectItem(combo, text) && FindInnerTextBox(combo) is TextBox late) { - late.Text = text; - late.SelectionStart = text.Length; + Apply(late, text); } }); } @@ -48,6 +54,38 @@ internal static class EditableComboBox + private static bool SelectItem(ComboBox combo, string text) + { + foreach(object item in combo.Items) + { + if(item is string candidate && string.Equals(candidate, text, System.StringComparison.Ordinal)) + { + combo.SelectedItem = item; + return true; + } + } + return false; + } + + + + /// + /// Writes the text and re-evaluates the placeholder: a TextBox whose Text is set + /// from code while it was collapsed keeps painting its placeholder over the text + /// (the wizard's step 3 box showed "Choose from the list" over a filled value); + /// touching PlaceholderText makes the control recompute that visual state. + /// + private static void Apply(TextBox box, string text) + { + box.Text = text; + box.SelectionStart = text.Length; + string placeholder = box.PlaceholderText; + box.PlaceholderText = ""; + box.PlaceholderText = placeholder; + } + + + private static TextBox? FindInnerTextBox(DependencyObject root) { int count = VisualTreeHelper.GetChildrenCount(root); diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index d591966..a515722 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -43,6 +43,11 @@ public sealed partial class MainWindow : Window private const int MinWindowHeight = 520; + /// Largest default window (logical pixels); the user can still resize beyond it. + private const int MaxDefaultWindowWidth = 1600; + + private const int MaxDefaultWindowHeight = 1100; + /// Set once the root's theme-change subscription exists. private bool _themeHooked; @@ -137,10 +142,20 @@ public sealed partial class MainWindow : Window WorkspaceNav.SelectedItem = QuickSyncNavItem; // AppWindow.Resize takes physical pixels; scale by the monitor DPI so the - // window is the same visual size at 150%/200% display scaling. + // window is the same visual size at 150%/200% display scaling. The default + // size follows the monitor: about 70% of the work area in each direction, + // never smaller than the minimum layout and never larger than a sensible + // desktop-app maximum, centred on that monitor (a fixed 1000x750 looked + // small on large displays and did not fit small ones). nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); double scale = GetDpiForWindow(hwnd) / 96.0; - AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale))); + Windows.Graphics.RectInt32 work = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea; + int width = (int)Math.Clamp(work.Width * 0.7, MinWindowWidth * scale, MaxDefaultWindowWidth * scale); + int height = (int)Math.Clamp(work.Height * 0.7, MinWindowHeight * scale, MaxDefaultWindowHeight * scale); + width = Math.Min(width, work.Width); + height = Math.Min(height, work.Height); + AppWindow.MoveAndResize(new Windows.Graphics.RectInt32( + work.X + (work.Width - width) / 2, work.Y + (work.Height - height) / 2, width, height)); // The brand icon in the title bar (and alt-tab / taskbar for unpackaged runs). AppWindow.SetIcon(System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "gclo.ico")); @@ -395,11 +410,18 @@ public sealed partial class MainWindow : Window { workspace.Page ??= CreatePage(workspace.ViewModel); WorkspaceNav.Content = workspace.Page; + AutoLoadAccountRepositories(workspace.ViewModel); } } + /// The view model decides whether showing this workspace loads it (saved accounts do). + private static void AutoLoadAccountRepositories(WorkspaceViewModel viewModel) + => viewModel.LoadRepositoriesIfSaved(); + + + private WorkspacePage CreatePage(WorkspaceViewModel viewModel) => new(viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) { @@ -639,15 +661,24 @@ public sealed partial class MainWindow : Window { return account.Name; } + // The pane is narrow: a short tag with the full meaning in the tooltip (and + // in the item's automation name), and the name trims before the tag does. var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - panel.Children.Add(new TextBlock { Text = account.Name, VerticalAlignment = VerticalAlignment.Center }); panel.Children.Add(new TextBlock { - Text = "Default token", + Text = account.Name, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + var tag = new TextBlock + { + Text = "Default", VerticalAlignment = VerticalAlignment.Center, Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - }); + }; + ToolTipService.SetToolTip(tag, "Uses the default token from Settings"); + panel.Children.Add(tag); return panel; } @@ -1057,7 +1088,7 @@ public sealed partial class MainWindow : Window private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e) { var dialog = new SettingsDialog( - _settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log) + _settings, _tokenVault, _accountsStore, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log) { XamlRoot = Content.XamlRoot, }; diff --git a/gclo/SettingsDialog.xaml b/gclo/SettingsDialog.xaml index 1f1bdba..e740afb 100644 --- a/gclo/SettingsDialog.xaml +++ b/gclo/SettingsDialog.xaml @@ -34,20 +34,33 @@ x:Name="DefaultTokenBox" Header="Default GitHub token" AutomationProperties.AutomationId="SettingsDefaultTokenBox" - ToolTipService.ToolTip="Pre-fills Quick Sync so you do not paste the token every launch. Stored in Windows Credential Manager, never in a file." /> - + PlaceholderText="ghp_…" + ToolTipService.ToolTip="Pre-fills Quick Sync and serves every account set to use it. Stored in Windows Credential Manager, never in a file." + PasswordChanged="DefaultTokenBox_PasswordChanged" /> + + + + + + - + _windowHandleProvider; - /// Set by the Remove link; the deletion happens on Save. - private bool _removeSavedToken; + /// + /// What the default-token box shows and what Save does with it: a saved token + /// shows as a fixed mask (never the token), typing replaces it, clearing removes + /// it, and the caption names the accounts a removal would break (#101). + /// + private readonly SettingsTokenState _tokenState; @@ -52,10 +57,15 @@ public sealed partial class SettingsDialog : ContentDialog /// records what a save changed (field names and values, /// never the token). /// - public SettingsDialog(AppSettings settings, ITokenVault vault, Func windowHandleProvider, IActivityLog? log = null) + public SettingsDialog( + AppSettings settings, ITokenVault vault, AccountsStore accounts, Func windowHandleProvider, IActivityLog? log = null) { ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(vault); + ArgumentNullException.ThrowIfNull(accounts); + _tokenState = new SettingsTokenState( + hasSavedToken: !string.IsNullOrEmpty(vault.TryRetrieve(AppSettings.DefaultTokenVaultId)), + dependentAccounts: accounts.GetAll().Where(a => a.UsesDefaultToken).Select(a => a.Name).ToList()); ArgumentNullException.ThrowIfNull(windowHandleProvider); _settings = settings; _vault = vault; @@ -74,9 +84,11 @@ public sealed partial class SettingsDialog : ContentDialog SplashToggle.IsOn = settings.ShowSplashScreen; SplashDurationBox.Value = settings.SplashMilliseconds; - // The saved token is never re-materialized into the box — the box stays - // empty and means "unchanged"; only typing a new value replaces it. - RefreshTokenState(hasSaved: _vault.TryRetrieve(AppSettings.DefaultTokenVaultId) is not null); + // The saved token is never re-materialized into the box: a saved token shows + // as the fixed mask, which means "unchanged"; typing replaces it, clearing + // removes it. The caption follows every edit (PasswordChanged). + DefaultTokenBox.Password = _tokenState.InitialBoxValue; + UpdateTokenCaption(); } @@ -145,15 +157,20 @@ public sealed partial class SettingsDialog : ContentDialog _settings.Save(); string typed = DefaultTokenBox.Password; - if(typed.Length > 0) + switch(_tokenState.Plan(typed)) { - _vault.Store(AppSettings.DefaultTokenVaultId, typed); - changes.Add("default token stored"); - } - else if(_removeSavedToken) - { - _vault.Delete(AppSettings.DefaultTokenVaultId); - changes.Add("default token removed"); + case SettingsTokenAction.Store: + _vault.Store(AppSettings.DefaultTokenVaultId, typed); + changes.Add(_tokenState.HasSavedToken ? "default token replaced" : "default token stored"); + break; + case SettingsTokenAction.Remove: + _vault.Delete(AppSettings.DefaultTokenVaultId); + changes.Add(_tokenState.DependentCount == 0 + ? "default token removed" + : $"default token removed ({_tokenState.DependentCount} account(s) depend on it)"); + break; + default: + break; } _log.Info(changes.Count == 0 @@ -163,22 +180,30 @@ public sealed partial class SettingsDialog : ContentDialog + // Clearing the box is the removal; the caption (via PasswordChanged) says what + // Save will do and which accounts it affects. private void RemoveTokenButton_Click(object sender, RoutedEventArgs e) + => DefaultTokenBox.Password = ""; + + + + private void DefaultTokenBox_PasswordChanged(object sender, RoutedEventArgs e) + => UpdateTokenCaption(); + + + + /// + /// The caption and the Remove link follow the box: the state object words the + /// consequence of Save (keep, replace, remove), and a removal that breaks accounts + /// is shown in the critical colour so it reads as the warning it is. + /// + private void UpdateTokenCaption() { - _removeSavedToken = true; - DefaultTokenBox.Password = ""; - TokenStateText.Text = "Saved token will be removed on Save."; - RemoveTokenButton.Visibility = Visibility.Collapsed; - } - - - - private void RefreshTokenState(bool hasSaved) - { - TokenStateText.Text = hasSaved - ? "A default token is saved. Leave the box empty to keep it." - : "No default token saved."; - RemoveTokenButton.Visibility = hasSaved ? Visibility.Visible : Visibility.Collapsed; + string box = DefaultTokenBox.Password; + TokenStateText.Text = _tokenState.Caption(box); + TokenStateText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[ + _tokenState.RemovalBreaksAccounts(box) ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"]; + RemoveTokenButton.Visibility = _tokenState.CanOfferRemoval(box) ? Visibility.Visible : Visibility.Collapsed; } diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index a4fbf8b..27404d5 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -19,12 +19,18 @@ + + 0 40 2* 160 120 - 96 + 112 @@ -543,6 +549,7 @@