feat(wizard): offer the default token when adding or editing an account #111
No files matched your search
@@ -390,6 +390,206 @@ public sealed class AccountWizardViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
private AccountWizardViewModel NewWizardWithDefault(string? defaultToken, Account? existing = null, string? existingToken = null)
|
||||
=> new(_store, _orgs, _defaults, existing, () => existingToken, defaultToken: () => defaultToken);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NewWizard_WithoutADefaultToken_OffersNoChoice_AndUsesAnOwnToken()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault(defaultToken: null);
|
||||
|
||||
Assert.False(wizard.HasDefaultToken);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.True(wizard.IsOwnTokenSelected);
|
||||
Assert.Equal(1, wizard.TokenChoiceIndex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NewWizard_WithADefaultToken_PreselectsIt()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
|
||||
Assert.True(wizard.HasDefaultToken);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.False(wizard.IsOwnTokenSelected);
|
||||
Assert.Equal(0, wizard.TokenChoiceIndex);
|
||||
Assert.Equal("", wizard.Token); // the default token is never loaded into the box
|
||||
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TokenChoiceIndex_RoundTripsToUseDefaultToken_AndNotifies()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
var changed = new List<string?>();
|
||||
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
wizard.TokenChoiceIndex = 1;
|
||||
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.IsOwnTokenSelected), StringComparison.Ordinal));
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.TokenChoiceIndex), StringComparison.Ordinal));
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.DefaultTokenCaption), StringComparison.Ordinal));
|
||||
|
||||
wizard.TokenChoiceIndex = 0;
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_UsingTheDefaultToken_ValidatesWithIt_AndSavesWithoutAnOwnEntry()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
wizard.Name = "Shared";
|
||||
string? validated = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
validated = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
Assert.Equal("ghp_default", validated);
|
||||
Assert.Equal("", wizard.TokenError);
|
||||
wizard.Organization = "acme";
|
||||
wizard.TargetRoot = @"C:\repos";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
Assert.Equal(1, _store.CountUsingDefaultToken());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_UsingTheDefaultToken_WhenItWasRemovedMeanwhile_StaysOnStepTwo()
|
||||
{
|
||||
string? liveDefault = "ghp_default";
|
||||
var wizard = new AccountWizardViewModel(_store, _orgs, _defaults, defaultToken: () => liveDefault);
|
||||
wizard.Name = "Shared";
|
||||
await AdvanceToStepAsync(wizard, 2);
|
||||
liveDefault = null; // removed in Settings while the wizard is open
|
||||
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.Equal(2, wizard.Step);
|
||||
Assert.Contains("default token is no longer saved", wizard.TokenError);
|
||||
Assert.Contains("Settings", wizard.TokenError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_ChoosingAnOwnToken_IgnoresTheDefault_AndStoresTheTypedOne()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
wizard.Name = "Mine";
|
||||
wizard.UseDefaultToken = false;
|
||||
wizard.Token = "ghp_mine";
|
||||
string? validated = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
validated = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
wizard.Organization = "acme";
|
||||
wizard.TargetRoot = @"C:\repos";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Assert.Equal("ghp_mine", validated);
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.False(saved.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EditWizard_DefaultTokenAccount_StartsOnTheDefault_AndCanSwitchToAnOwnToken()
|
||||
{
|
||||
Account existing = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
_store.Save(existing, null);
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal); // itself excluded
|
||||
|
||||
wizard.UseDefaultToken = false;
|
||||
await AdvanceToStepAsync(wizard, 2);
|
||||
Assert.False(await wizard.TryAdvanceAsync()); // no own token typed yet
|
||||
Assert.Contains("instead of the default one", wizard.TokenError);
|
||||
|
||||
wizard.Token = "ghp_mine";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.False(saved.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EditWizard_OwnTokenAccount_SwitchingToTheDefault_RemovesItsOwnEntry_AndSaysSo()
|
||||
{
|
||||
Account existing = MakeAccount("Work");
|
||||
_store.Save(existing, "ghp_own");
|
||||
_store.Save(MakeAccount("Other") with { TokenSource = TokenSource.Default }, null);
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing, "ghp_own");
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
|
||||
wizard.UseDefaultToken = true;
|
||||
|
||||
Assert.Contains("1 other account", wizard.DefaultTokenCaption);
|
||||
Assert.Contains("own token will be removed", wizard.DefaultTokenCaption);
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
Account saved = _store.FindByName("Work")!;
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultTokenCaption_CountsOtherAccounts()
|
||||
{
|
||||
_store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
|
||||
_store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
|
||||
|
||||
Assert.Contains("2 other accounts", NewWizardWithDefault("ghp_default").DefaultTokenCaption);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void SeededWizard_PreselectsTheDefault_OnlyWhenTheSeedTokenIsTheDefaultToken()
|
||||
{
|
||||
var onDefault = new AccountWizardSeed("ghp_default", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
|
||||
var other = new AccountWizardSeed("ghp_other", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
|
||||
|
||||
Assert.True(new AccountWizardViewModel(_store, _orgs, onDefault, defaultToken: () => "ghp_default").UseDefaultToken);
|
||||
Assert.False(new AccountWizardViewModel(_store, _orgs, other, defaultToken: () => "ghp_default").UseDefaultToken);
|
||||
Assert.False(new AccountWizardViewModel(_store, _orgs, onDefault).UseDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void SeededWizard_NullSeed_Throws()
|
||||
{
|
||||
|
||||
@@ -132,6 +132,69 @@ public sealed class SyncAllCoordinatorTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
/// <summary>A default-token account workspace; the default token is seeded only when asked.</summary>
|
||||
private WorkspaceViewModel CreateDefaultTokenWorkspace(string name, bool withDefaultToken)
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = "org-" + name,
|
||||
TargetRoot = Path.Combine(_root, name),
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
if(withDefaultToken)
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "tok-default");
|
||||
}
|
||||
var workspace = new WorkspaceViewModel(
|
||||
_lister,
|
||||
new FakeGitClient(),
|
||||
_orgs,
|
||||
handler => new SyncProgress(handler),
|
||||
TimeSpan.FromMilliseconds(1),
|
||||
new NullActivityLog(),
|
||||
account,
|
||||
_vault);
|
||||
_workspaces.Add(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DefaultTokenAccount_WithoutADefaultToken_IsSkipped_NotRun()
|
||||
{
|
||||
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: false);
|
||||
Assert.True(shared.UsesDefaultToken);
|
||||
Assert.Equal("", shared.Token);
|
||||
var states = new List<SyncAllAccountState>();
|
||||
_coordinator.AccountStateChanged = (_, state) => states.Add(state);
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.Ran);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Skipped], states);
|
||||
Assert.Empty(_lister.Organizations); // never asked GitHub: skipped before loading
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DefaultTokenAccount_WithADefaultToken_Runs()
|
||||
{
|
||||
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: true);
|
||||
Assert.Equal("tok-default", shared.Token);
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Ran);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ordering
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1816,6 +1816,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault);
|
||||
|
||||
Assert.Equal("default-token-1234567890", vm.Token);
|
||||
Assert.True(vm.UsesDefaultToken);
|
||||
await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire");
|
||||
Assert.Equal("default-token-1234567890", lookedUpToken);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// </summary>
|
||||
private readonly Func<string?>? _storedToken;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the default token from Settings on demand (null, or returning null,
|
||||
/// when none is saved). Read when step 2 validates or the account is saved with
|
||||
/// "Use the default token"; never copied into <see cref="Token"/> (#102).
|
||||
/// </summary>
|
||||
private readonly Func<string?>? _defaultToken;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A wizard for a new account seeded from <paramref name="defaults"/>, or — when
|
||||
/// <paramref name="existing"/> is given — an edit wizard seeded from that account.
|
||||
/// <paramref name="storedToken"/> fetches the vault's current token on demand for
|
||||
/// an edit (null, or returning null, when the vault has no entry).
|
||||
/// an edit (null, or returning null, when the vault has no entry);
|
||||
/// <paramref name="defaultToken"/> fetches the default token from Settings the
|
||||
/// same way and, when it returns one, offers "Use the default token" on step 2.
|
||||
/// </summary>
|
||||
public AccountWizardViewModel(
|
||||
AccountsStore store,
|
||||
@@ -50,8 +59,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
AppSettings defaults,
|
||||
Account? existing = null,
|
||||
Func<string?>? storedToken = null,
|
||||
IActivityLog? log = null)
|
||||
: this(store, orgLister, existing, storedToken, log)
|
||||
IActivityLog? log = null,
|
||||
Func<string?>? defaultToken = null)
|
||||
: this(store, orgLister, existing, storedToken, defaultToken, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
|
||||
@@ -85,13 +95,19 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// always written to the vault on save.
|
||||
/// </summary>
|
||||
public AccountWizardViewModel(
|
||||
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null)
|
||||
: this(store, orgLister, existing: null, storedToken: null, log)
|
||||
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
|
||||
Func<string?>? defaultToken = null)
|
||||
: this(store, orgLister, existing: null, storedToken: null, defaultToken, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seed);
|
||||
Name = "";
|
||||
Description = "";
|
||||
Token = seed.Token;
|
||||
// A Quick Sync connection that runs on the default token becomes a
|
||||
// default-token account; any other token is the account's own. Compared in
|
||||
// memory only; neither value is logged.
|
||||
UseDefaultToken = HasDefaultToken
|
||||
&& string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal);
|
||||
Organization = seed.Organization;
|
||||
TargetRoot = seed.TargetRoot;
|
||||
CreateOrgSubfolder = seed.CreateOrgSubfolder;
|
||||
@@ -101,7 +117,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
|
||||
|
||||
private AccountWizardViewModel(
|
||||
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken, IActivityLog? log)
|
||||
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken,
|
||||
Func<string?>? defaultToken, IActivityLog? log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(orgLister);
|
||||
@@ -110,6 +127,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
_log = log ?? new NullActivityLog();
|
||||
_existing = existing;
|
||||
_storedToken = existing is null ? null : storedToken;
|
||||
_defaultToken = defaultToken;
|
||||
// Decided once: the choice is offered only when a default token exists at
|
||||
// open time, and the value itself is fetched again when a step needs it.
|
||||
HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke());
|
||||
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
|
||||
|
||||
Step = 1;
|
||||
NameError = "";
|
||||
@@ -167,6 +189,59 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
public partial bool IsValidatingToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when a default token was saved in Settings when the wizard opened, which
|
||||
/// is when step 2 offers the choice between it and a token of the account's own.
|
||||
/// </summary>
|
||||
public bool HasDefaultToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Step 2's choice: true means the account resolves to the default token from
|
||||
/// Settings (<see cref="TokenSource.Default"/>) and the token box is disabled;
|
||||
/// false means a token of its own. Preselected for a new account when a default
|
||||
/// token exists, and from the account's current source for an edit.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsOwnTokenSelected))]
|
||||
[NotifyPropertyChangedFor(nameof(TokenChoiceIndex))]
|
||||
[NotifyPropertyChangedFor(nameof(DefaultTokenCaption))]
|
||||
public partial bool UseDefaultToken { get; set; }
|
||||
|
||||
/// <summary>The token box is enabled only for a token of the account's own.</summary>
|
||||
public bool IsOwnTokenSelected => !UseDefaultToken;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
|
||||
/// 0 = the default token, 1 = a token of this account's own.
|
||||
/// </summary>
|
||||
public int TokenChoiceIndex
|
||||
{
|
||||
get => UseDefaultToken ? 0 : 1;
|
||||
set => UseDefaultToken = value == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What choosing the default token means for this account, with how many other
|
||||
/// accounts share it; shown under the radio button.
|
||||
/// </summary>
|
||||
public string DefaultTokenCaption
|
||||
{
|
||||
get
|
||||
{
|
||||
int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0);
|
||||
string shared = others switch
|
||||
{
|
||||
0 => "Used by Quick Sync.",
|
||||
1 => "Used by Quick Sync and 1 other account.",
|
||||
_ => $"Used by Quick Sync and {others} other accounts.",
|
||||
};
|
||||
string switching = IsEditing && _existing?.UsesDefaultToken == false && UseDefaultToken
|
||||
? " This account's own token will be removed from Windows Credential Manager on save."
|
||||
: "";
|
||||
return shared + " Replacing it in Settings updates this account too." + switching;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Step 1's validation message; empty when the name is acceptable.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string NameError { get; set; }
|
||||
@@ -254,16 +329,34 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// token and returns the organizations the dropdown offers otherwise.
|
||||
// An edit with the box left empty validates the STORED token, fetched
|
||||
// here and used for this one call only.
|
||||
string candidate = Token.Trim();
|
||||
if(candidate.Length == 0 && IsEditing)
|
||||
string candidate;
|
||||
if(UseDefaultToken)
|
||||
{
|
||||
candidate = _storedToken?.Invoke()?.Trim() ?? "";
|
||||
// The default token is fetched for this one call only; the box
|
||||
// stays untouched (and disabled) so it is never shown or retained.
|
||||
candidate = _defaultToken?.Invoke()?.Trim() ?? "";
|
||||
if(candidate.Length == 0)
|
||||
{
|
||||
TokenError = "This account has no stored token. Enter one to continue.";
|
||||
TokenError = "The default token is no longer saved. Save one in Settings, "
|
||||
+ "or use a different token for this account.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate = Token.Trim();
|
||||
if(candidate.Length == 0 && IsEditing)
|
||||
{
|
||||
candidate = _storedToken?.Invoke()?.Trim() ?? "";
|
||||
if(candidate.Length == 0)
|
||||
{
|
||||
TokenError = _existing?.UsesDefaultToken == true
|
||||
? "Enter the token this account should use instead of the default one."
|
||||
: "This account has no stored token. Enter one to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IsValidatingToken = true;
|
||||
_log.Info("Account wizard: validating the token.");
|
||||
@@ -333,7 +426,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// </summary>
|
||||
public Task SaveAsync()
|
||||
{
|
||||
_store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null);
|
||||
// A default-token account stores no token of its own (the store removes any
|
||||
// leftover entry when an account switches); otherwise new accounts always
|
||||
// persist their token and edits only when one was typed.
|
||||
_store.Save(BuildAccount(), !UseDefaultToken && TokenChanged ? Token.Trim() : null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -353,6 +449,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
TargetRoot = TargetRoot.Trim(),
|
||||
CreateOrgSubfolder = CreateOrgSubfolder,
|
||||
MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency),
|
||||
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
|
||||
LastSyncUtc = _existing?.LastSyncUtc,
|
||||
LastSyncSummary = _existing?.LastSyncSummary,
|
||||
};
|
||||
|
||||
@@ -78,6 +78,16 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
continue;
|
||||
}
|
||||
|
||||
if(workspace.UsesDefaultToken && string.IsNullOrEmpty(workspace.Token))
|
||||
{
|
||||
// The account resolves to the default token and none is saved: a
|
||||
// generic auth failure would send the user to the wrong place.
|
||||
skipped++;
|
||||
Announce(workspace, SyncAllAccountState.Skipped);
|
||||
_log.Info($"Sync all: skipped '{workspace.DisplayName}' — no default token is saved (Settings).");
|
||||
continue;
|
||||
}
|
||||
|
||||
Announce(workspace, SyncAllAccountState.Running);
|
||||
|
||||
if(!workspace.HasLoadedRepos)
|
||||
|
||||
@@ -157,6 +157,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Name shown for this workspace in navigation: the account's name, or "Quick Sync".</summary>
|
||||
public string DisplayName => _account?.Name ?? "Quick Sync";
|
||||
|
||||
/// <summary>
|
||||
/// True for an account workspace whose token is the default token from Settings
|
||||
/// (<see cref="TokenSource.Default"/>): the pane tags it, and Sync All names
|
||||
/// Settings when that token is missing.
|
||||
/// </summary>
|
||||
public bool UsesDefaultToken => _account?.UsesDefaultToken == true;
|
||||
|
||||
|
||||
|
||||
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
@@ -57,12 +57,31 @@
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 2 of 4: Access token" />
|
||||
<!-- Offered only when a default token is saved in Settings (#102). The
|
||||
account then references that token instead of holding a copy, so
|
||||
replacing it in Settings updates every such account. -->
|
||||
<RadioButtons
|
||||
x:Name="TokenChoice"
|
||||
AutomationProperties.AutomationId="WizardTokenChoice"
|
||||
Header="Which token should this account use?"
|
||||
SelectedIndex="{x:Bind ViewModel.TokenChoiceIndex, Mode=TwoWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasDefaultToken), Mode=OneWay}">
|
||||
<RadioButton AutomationProperties.AutomationId="WizardUseDefaultToken" Content="Use the default token from Settings" />
|
||||
<RadioButton AutomationProperties.AutomationId="WizardUseOwnToken" Content="Use a different token for this account" />
|
||||
</RadioButtons>
|
||||
<TextBlock
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="{x:Bind ViewModel.DefaultTokenCaption, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.UseDefaultToken), Mode=OneWay}" />
|
||||
<PasswordBox
|
||||
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_…"
|
||||
IsEnabled="{x:Bind ViewModel.IsOwnTokenSelected, Mode=OneWay}"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
<HyperlinkButton
|
||||
Padding="0"
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed partial class AccountWizardDialog : ContentDialog
|
||||
// by hand. The stored token of an existing account is deliberately NOT
|
||||
// loaded into the box: an empty box means "keep it", and the secret never
|
||||
// sits in an editable control with a reveal button (#32).
|
||||
TokenBox.PlaceholderText = ViewModel.IsEditing
|
||||
TokenBox.PlaceholderText = ViewModel.IsEditing && !ViewModel.UseDefaultToken
|
||||
? "Leave empty to keep the stored token"
|
||||
: "ghp_…";
|
||||
|
||||
|
||||
+34
-4
@@ -615,11 +615,12 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
var item = new NavigationViewItem
|
||||
{
|
||||
Content = account.Name,
|
||||
Content = AccountItemContent(account),
|
||||
Tag = account.Id,
|
||||
Icon = new FontIcon { Glyph = "" },
|
||||
ContextFlyout = CreateAccountContextFlyout(account.Id),
|
||||
};
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
|
||||
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
|
||||
_navItems[account.Id] = item;
|
||||
return item;
|
||||
@@ -627,6 +628,31 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The pane item's content: the name, plus a "Default token" tag for an account
|
||||
/// that resolves to the default token from Settings, so the dependency on
|
||||
/// Settings is visible where the account is used (#102).
|
||||
/// </summary>
|
||||
private static object AccountItemContent(Account account)
|
||||
{
|
||||
if(!account.UsesDefaultToken)
|
||||
{
|
||||
return account.Name;
|
||||
}
|
||||
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",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The Edit/Delete context menu for an account's pane item.</summary>
|
||||
private MenuFlyout CreateAccountContextFlyout(Guid id)
|
||||
{
|
||||
@@ -688,7 +714,8 @@ public sealed partial class MainWindow : Window
|
||||
// validation call; the wizard never displays or retains it.
|
||||
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
|
||||
var viewModel = new AccountWizardViewModel(
|
||||
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log);
|
||||
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log,
|
||||
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -737,7 +764,9 @@ public sealed partial class MainWindow : Window
|
||||
/// </summary>
|
||||
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
|
||||
{
|
||||
var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log);
|
||||
var viewModel = new AccountWizardViewModel(
|
||||
_accountsStore, new GitHubOrganizationLister(), seed, _log,
|
||||
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -798,7 +827,8 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
if(_navItems.TryGetValue(id, out NavigationViewItem? item))
|
||||
{
|
||||
item.Content = account.Name;
|
||||
item.Content = AccountItemContent(account);
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
|
||||
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user