feat(settings): show a saved default token as a mask and say what Save will do #112
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<IReadOnlyList<string>>(["KofTwentyTwo", "other"]);
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
|
||||
Assert.Equal("KofTwentyTwo", wizard.Organization);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultTokenCaption_CountsOtherAccounts()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SettingsTokenState"/>: the mask stands for a saved token, Save's action
|
||||
/// follows from what the box holds, and the captions name the consequence (#101).
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -363,11 +363,18 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> 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.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>What Save does with the default-token box (<see cref="SettingsTokenState.Plan"/>).</summary>
|
||||
public enum SettingsTokenAction
|
||||
{
|
||||
/// <summary>Leave the vault as it is: the mask is untouched, or nothing was saved and nothing typed.</summary>
|
||||
Keep = 0,
|
||||
|
||||
/// <summary>Store what was typed as the default token (replacing a saved one).</summary>
|
||||
Store = 1,
|
||||
|
||||
/// <summary>Delete the saved default token: the box was cleared or Remove was pressed.</summary>
|
||||
Remove = 2,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Mask"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SettingsTokenState
|
||||
{
|
||||
/// <summary>What the box shows while a saved token is untouched. A constant, not the token.</summary>
|
||||
public const string Mask = "••••••••••••••••";
|
||||
|
||||
private readonly IReadOnlyList<string> _dependentAccounts;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="hasSavedToken"/>: whether the vault holds a default token;
|
||||
/// <paramref name="dependentAccounts"/>: the names of the accounts that resolve
|
||||
/// to it (<see cref="TokenSource.Default"/>), which a removal would break.
|
||||
/// </summary>
|
||||
public SettingsTokenState(bool hasSavedToken, IReadOnlyList<string> dependentAccounts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependentAccounts);
|
||||
HasSavedToken = hasSavedToken;
|
||||
_dependentAccounts = dependentAccounts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the vault held a default token when the dialog opened.</summary>
|
||||
public bool HasSavedToken { get; }
|
||||
|
||||
/// <summary>How many accounts resolve to the default token.</summary>
|
||||
public int DependentCount => _dependentAccounts.Count;
|
||||
|
||||
/// <summary>The initial box value: the mask when a token is saved, empty otherwise.</summary>
|
||||
public string InitialBoxValue => HasSavedToken ? Mask : "";
|
||||
|
||||
/// <summary>True when "Remove saved token" applies: a token is saved and the box still shows the mask.</summary>
|
||||
public bool CanOfferRemoval(string boxValue) => HasSavedToken && string.Equals(boxValue, Mask, StringComparison.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>What Save will do with <paramref name="boxValue"/>, the box's current content.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The caption under the box for <paramref name="boxValue"/>; names what Save will do.</summary>
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when Save would remove a token that accounts depend on; the caption then carries the warning.</summary>
|
||||
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}.";
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,26 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 3 of 4: Organization" />
|
||||
<ComboBox
|
||||
x:Name="OrgBox"
|
||||
AutomationProperties.AutomationId="WizardOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
|
||||
@@ -63,6 +63,27 @@ public sealed partial class AccountWizardDialog : ContentDialog
|
||||
|
||||
// NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged).
|
||||
ConcurrencyBox.Value = ViewModel.MaxConcurrency;
|
||||
|
||||
// Step 3's panel is collapsed while step 2's validation replaces the
|
||||
// organization list, so the editable ComboBox shows up blank even though the
|
||||
// view model still holds Organization (an edit seeds it): put the text back
|
||||
// after the panel has laid out, every time the wizard arrives on step 3.
|
||||
ViewModel.PropertyChanged += (_, e) =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Whether <see cref="OrgBox_Loaded"/> already follows the organization list.</summary>
|
||||
private bool _organizationsHooked;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The view model whose organization list this control already follows.</summary>
|
||||
private WorkspaceViewModel? _organizationsHooked;
|
||||
|
||||
|
||||
|
||||
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(ViewModel is null || WindowHandleProvider is not { } handle)
|
||||
|
||||
@@ -27,20 +27,26 @@ internal static class EditableComboBox
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
+37
-6
@@ -43,6 +43,11 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private const int MinWindowHeight = 520;
|
||||
|
||||
/// <summary>Largest default window (logical pixels); the user can still resize beyond it.</summary>
|
||||
private const int MaxDefaultWindowWidth = 1600;
|
||||
|
||||
private const int MaxDefaultWindowHeight = 1100;
|
||||
|
||||
/// <summary>Set once the root's theme-change subscription exists.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The view model decides whether showing this workspace loads it (saved accounts do).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
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" />
|
||||
<!-- Caption takes the width, the Remove link keeps its own: a horizontal
|
||||
StackPanel clipped the link once the caption grew (#101). -->
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
x:Name="TokenStateText"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
AutomationProperties.LiveSetting="Polite"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<HyperlinkButton
|
||||
x:Name="RemoveTokenButton"
|
||||
Grid.Column="1"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Remove saved token"
|
||||
ToolTipService.ToolTip="Deletes the saved default token from Windows Credential Manager when you press Save"
|
||||
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
|
||||
Click="RemoveTokenButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<NumberBox
|
||||
x:Name="ConcurrencyBox"
|
||||
Header="Default parallelism"
|
||||
|
||||
+53
-28
@@ -5,6 +5,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -42,8 +43,12 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
// A ContentDialog has no HWND of its own; the host window supplies one for pickers.
|
||||
private readonly Func<nint> _windowHandleProvider;
|
||||
|
||||
/// <summary>Set by the Remove link; the deletion happens on Save.</summary>
|
||||
private bool _removeSavedToken;
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private readonly SettingsTokenState _tokenState;
|
||||
|
||||
|
||||
|
||||
@@ -52,10 +57,15 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
/// <paramref name="log"/> records what a save changed (field names and values,
|
||||
/// never the token).
|
||||
/// </summary>
|
||||
public SettingsDialog(AppSettings settings, ITokenVault vault, Func<nint> windowHandleProvider, IActivityLog? log = null)
|
||||
public SettingsDialog(
|
||||
AppSettings settings, ITokenVault vault, AccountsStore accounts, Func<nint> 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();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,12 +19,18 @@
|
||||
<Setter Property="Padding" Value="4" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<!-- The row-details presenter is shown for every row (RowDetailsVisibilityMode
|
||||
Visible, so error/note/Resolve lines can appear without a toggle) and
|
||||
the TableView theme pads it 8px on every side even when the details
|
||||
content is collapsed, which left an empty band under each row. The
|
||||
RowDetailsTemplate pads its own content, so the theme padding is 0 here. -->
|
||||
<Thickness x:Key="TableViewDetailsPadding">0</Thickness>
|
||||
<!-- Column widths of the repository table, declared once. -->
|
||||
<GridLength x:Key="SelectColumnWidth">40</GridLength>
|
||||
<GridLength x:Key="NameColumnWidth">2*</GridLength>
|
||||
<GridLength x:Key="StatusColumnWidth">160</GridLength>
|
||||
<GridLength x:Key="BranchColumnWidth">120</GridLength>
|
||||
<GridLength x:Key="ArchivedColumnWidth">96</GridLength>
|
||||
<GridLength x:Key="ArchivedColumnWidth">112</GridLength>
|
||||
<!-- Header automation names are set from code (sort state is composed in), but
|
||||
every header needs a sensible name before the first sort; one style per
|
||||
column since Style setters cannot bind. -->
|
||||
@@ -543,6 +549,7 @@
|
||||
<CheckBox
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)"
|
||||
AutomationProperties.Name="Select all visible repositories"
|
||||
@@ -553,6 +560,7 @@
|
||||
<CheckBox
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
|
||||
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
|
||||
|
||||
Reference in new issue
Block a user