Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 9667f437b7 feat(wizard): offer the default token when adding or editing an account
Step 2 of the account wizard becomes a choice when a default token is saved in
Settings: "Use the default token from Settings" (preselected for a new account,
or when the Quick Sync connection being saved runs on it) or "Use a different
token for this account". The default token is fetched only to validate and is
never loaded into the box, which is disabled while the default is selected.
Editing switches either way: choosing the default says the account's own token
will be removed on save, choosing an own token on a default-token account asks
for one, and a default token removed while the wizard is open gives a
Settings-specific message instead of a generic failure.

The pane tags default-token accounts ("Default token", also in the item's
automation name), and Sync All skips such an account with no default token
saved, naming Settings, before it would hit a generic auth error.

Closes #105
Part of #102

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 17:11:10 -05:00
james.maesandClaude Fable 5.1 c0a99cacd8 feat(accounts): reference the default token instead of copying it
An account can now resolve to the default token saved in Settings
(TokenSource = Default) instead of holding a copy of its own, so replacing the
default token rotates every such account at once. Own stays the on-disk
default and is omitted from accounts.json, so existing files and vault entries
are untouched.

AccountTokenResolver is the one place that turns an account into its token;
the desktop workspace, Sync All and the CLI go through it. AccountsStore saves
a Default account without a vault entry and removes an own entry when an
account switches, vault first with the same put-it-back compensation as a
delete; CountUsingDefaultToken feeds the Settings captions in step 3.

CLI parity: `accounts add` and `accounts edit` take --use-default-token (a
fatal error when no default token is saved; exclusive with the other token
options), `accounts list` shows the source as a column and as tokenSource in
JSON, and `sync --account` names the source and the fix when no token
resolves. Threat model T5 and docs/CLI.md updated; the approved plan is in
docs/plans/default-token-ux.md.

Closes #104
Part of #102

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 17:03:16 -05:00
james.maesandClaude Fable 5.1 ef86a52bc6 docs(plans): UX plan for the default token and account tokens
Design for #101 (Settings shows a saved default token as a masked value) and #102 (Add account uses the default token by reference, so rotating it in Settings rotates every such account): TokenSource on the account record, one resolver for app and CLI, wizard step 2 as a choice, guarded removal in Settings, CLI parity, tests and delivery order.

Relates to #101, #102

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 16:44:57 -05:00
23 changed files with 72 additions and 742 deletions

No files matched your search

+5 -12
View File
@@ -153,10 +153,7 @@ jobs:
with:
solution: gclo.slnx
app-project: gclo/gclo.csproj
# Prereleases pack as their own Velopack app (gclo-dev): separate install
# folder, Start menu entry, update feed and data root, so a dev build can be
# installed beside the stable one (#114). Asset names follow the app id.
app-id: ${{ contains(github.ref_name, '-') && 'gclo-dev' || 'gclo' }}
app-id: gclo
main-exe: gclo.exe
icon: gclo/Assets/Brand/gclo.ico
platform: x64
@@ -187,16 +184,12 @@ jobs:
needs: release
runs-on: windows-latest
timeout-minutes: 15
# TRUSTED_SIGNING_SUBJECT is a `release`-environment variable, so the job must
# run in that environment to read it (v1.0.2-beta.3 failed here with it unset).
environment: release
permissions:
contents: read
env:
TAG: ${{ github.ref_name }}
K22_VERSION: ${{ needs.release.outputs.version }}
K22_CHANNEL: ${{ needs.release.outputs.prerelease == 'true' && 'dev' || 'stable' }}
K22_APP_ID: ${{ needs.release.outputs.prerelease == 'true' && 'gclo-dev' || 'gclo' }}
EXPECTED_SUBJECT: ${{ vars.TRUSTED_SIGNING_SUBJECT }}
steps:
- name: Download the published assets
@@ -206,10 +199,10 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
New-Item -ItemType Directory -Force assets | Out-Null
gh release download $env:TAG --dir assets --pattern "$env:K22_APP_ID-$env:K22_CHANNEL-Setup.exe" --pattern "$env:K22_APP_ID-$env:K22_CHANNEL-Portable.zip" --pattern "$env:K22_APP_ID-cli-win-x64-$env:K22_VERSION.zip"
gh release download $env:TAG --dir assets --pattern "gclo-$env:K22_CHANNEL-Setup.exe" --pattern "gclo-$env:K22_CHANNEL-Portable.zip" --pattern "gclo-cli-win-x64-$env:K22_VERSION.zip"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Expand-Archive assets/$env:K22_APP_ID-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable
Expand-Archive assets/$env:K22_APP_ID-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli
Expand-Archive assets/gclo-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable
Expand-Archive assets/gclo-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli
- name: signtool verify /pa /all on every shipped executable
shell: pwsh
@@ -221,7 +214,7 @@ jobs:
}
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
$files = @(Get-Item assets/$env:K22_APP_ID-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
$files = @(Get-Item assets/gclo-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
Where-Object { $_.Name -like 'gclo*' -or $_.Name -in 'Update.exe' })
$failed = 0
foreach ($f in $files)
-18
View File
@@ -23,24 +23,6 @@ Any prerelease identifier works (`-alpha`, `-beta.2`, `-rc.1`, ...) — the rule
is simply: **a `-` in the version means prerelease, which means the `dev`
channel**.
### The dev edition is a separate app
A prerelease is packed as its own Velopack app, id **`gclo-dev`**, while stable
releases use **`gclo`**. The two install side by side: separate install folder
(`%LOCALAPPDATA%\gclo-dev` vs `%LOCALAPPDATA%\gclo`), Start menu entry, update
feed and data root (`%LOCALAPPDATA%\KofTwentyTwo\gclo-dev` vs `…\gclo`), and
the dev edition's window title reads "gclo (dev)". A machine can therefore keep
the stable app for real work and a dev build for trying the next release; each
only ever updates within its own channel. The dev edition's assets follow its
app id: `gclo-dev-dev-Setup.exe`, `gclo-dev-dev-Portable.zip`,
`gclo-dev-1.2.3-beta.1-dev-full.nupkg`, `gclo-dev-cli-win-x64-<version>.zip`,
`releases.dev.json`. Both editions read the same Windows Credential Manager
entries (the default token saved in one is available in the other; account
tokens are keyed by account id, and each edition has its own accounts), so a
dev build needs no token re-entry. Dev installs made before this split (app id
`gclo` on the dev channel, up to 1.0.2-beta.3) cannot update into `gclo-dev`:
uninstall them, or install the stable app over them.
### How self-update channels map
The desktop app updates itself with [Velopack](https://velopack.io). Each
+3 -6
View File
@@ -46,12 +46,9 @@
| 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 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.
→ 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.
Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the
mask is announced as the field's value by the password pattern (dots), the
@@ -566,24 +566,6 @@ 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()
{
-15
View File
@@ -30,21 +30,6 @@ public sealed class GcloPathsTests
/// <summary>The dev edition keeps its own data root beside the stable one (#114).</summary>
[Fact]
public void DataFolderNameFor_DevEdition_UsesItsOwnFolder()
{
// Edition itself is process-wide state set once by the app's entry point; the
// tests exercise the pure mapping so they cannot race the vault's scoping.
Assert.Equal("gclo", GcloPaths.DataFolderNameFor(""));
Assert.Equal("gclo-dev", GcloPaths.DataFolderNameFor("dev"));
Assert.Throws<ArgumentNullException>(() => GcloPaths.DataFolderNameFor(null!));
Assert.Equal("", GcloPaths.Edition);
Assert.Equal("gclo", GcloPaths.DataFolderName);
}
/// <summary>The default root is never the install root nor anything under it.</summary>
[Fact]
public void DefaultDataRoot_IsNotTheVelopackInstallRoot()
@@ -1,142 +0,0 @@
/*
* 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,66 +1823,6 @@ 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,18 +363,11 @@ 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.");
}
+3 -28
View File
@@ -34,36 +34,11 @@ public static class GcloPaths
/// <summary>
/// The installed edition: "" for the stable app (Velopack app id <c>gclo</c>) and
/// "dev" for the dev-channel app (<c>gclo-dev</c>), which installs beside the
/// stable one and keeps its own data root so both can run on one machine (#114).
/// Set once by the app's entry point before anything reads <see cref="DataRoot"/>;
/// local and CLI runs leave it empty.
/// </summary>
public static string Edition { get; set; } = "";
/// <summary>The folder name under %LOCALAPPDATA%\KofTwentyTwo: "gclo", or "gclo-dev" for the dev edition.</summary>
public static string DataFolderName => DataFolderNameFor(Edition);
/// <summary>"gclo" for the stable edition (empty <paramref name="edition"/>), "gclo-&lt;edition&gt;" otherwise.</summary>
public static string DataFolderNameFor(string edition)
{
ArgumentNullException.ThrowIfNull(edition);
return edition.Length == 0 ? "gclo" : $"gclo-{edition}";
}
/// <summary>
/// %LOCALAPPDATA%\KofTwentyTwo\gclo (or gclo-dev): the root used when GCLO_DATA_DIR
/// is not set. Outside the Velopack install root on purpose (see the type remarks).
/// %LOCALAPPDATA%\KofTwentyTwo\gclo: the root used when GCLO_DATA_DIR is not
/// set. Outside the Velopack install root on purpose (see the type remarks).
/// </summary>
public static string DefaultDataRoot =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KofTwentyTwo", DataFolderName);
"KofTwentyTwo", "gclo");
}
-20
View File
@@ -1,20 +0,0 @@
/*
* 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,
}
-125
View File
@@ -1,125 +0,0 @@
/*
* 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}.";
}
}
-20
View File
@@ -166,26 +166,6 @@ 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>
-1
View File
@@ -121,7 +121,6 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 3 of 4: Organization" />
<ComboBox
x:Name="OrgBox"
AutomationProperties.AutomationId="WizardOrgBox"
HorizontalAlignment="Stretch"
Header="Organization or account"
+1 -38
View File
@@ -63,27 +63,6 @@ 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));
}
};
}
@@ -203,23 +182,7 @@ 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)
{
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;
=> EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
-1
View File
@@ -37,7 +37,6 @@
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}"
+12 -34
View File
@@ -79,35 +79,28 @@ 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 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).
// 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).
private void OnLoaded(object sender, RoutedEventArgs e)
{
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.");
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";
}
// PasswordBox does not support reliable two-way x:Bind on Password; mirror it
// 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.
// into the view model by hand.
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
{
string typed = ((PasswordBox)sender).Password;
if(ViewModel is not null && !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal))
if(ViewModel is not null)
{
ViewModel.Token = typed;
ViewModel.Token = ((PasswordBox)sender).Password;
}
}
@@ -121,26 +114,11 @@ 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)
+5 -43
View File
@@ -27,26 +27,20 @@ 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)
{
Apply(inner, text);
inner.Text = text;
inner.SelectionStart = text.Length;
}
else
{
// Template not applied yet: try once more after this layout pass.
combo.DispatcherQueue.TryEnqueue(() =>
{
if(!SelectItem(combo, text) && FindInnerTextBox(combo) is TextBox late)
if(FindInnerTextBox(combo) is TextBox late)
{
Apply(late, text);
late.Text = text;
late.SelectionStart = text.Length;
}
});
}
@@ -54,38 +48,6 @@ 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);
+1 -4
View File
@@ -30,10 +30,7 @@
<MenuFlyoutItem Text="Activity log…" AccessKey="A" AutomationProperties.AutomationId="MenuActivityLog" Click="ActivityLogMenuItem_Click" />
</MenuBarItem>
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" AutomationProperties.AutomationId="MenuGitHub" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature on GitHub" Click="SubmitIssueMenuItem_Click" />
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="Ask a question or share tips in GitHub Discussions" Click="CommunitySupportMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem x:Name="CheckUpdatesMenuItem" Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="About gclo…" AccessKey="A" AutomationProperties.AutomationId="MenuAbout" Click="AboutMenuItem_Click">
+9 -62
View File
@@ -43,11 +43,6 @@ 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;
@@ -121,10 +116,7 @@ public sealed partial class MainWindow : Window
InitializeComponent();
ApplyBackdrop();
// The dev edition installs beside the stable app, so the title says which one this is.
Title = GcloPaths.Edition.Length == 0
? "gclo — Git Clone Large Organizations"
: $"gclo ({GcloPaths.Edition}) — Git Clone Large Organizations";
Title = "gclo — Git Clone Large Organizations";
// Quick Sync is declared in XAML; its Guid tag and the account items are
// runtime data, so they are filled in here.
@@ -145,20 +137,10 @@ 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. 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).
// window is the same visual size at 150%/200% display scaling.
nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
double scale = GetDpiForWindow(hwnd) / 96.0;
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));
AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale)));
// 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"));
@@ -413,18 +395,11 @@ 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))
{
@@ -664,24 +639,15 @@ 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 = account.Name,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
});
var tag = new TextBlock
{
Text = "Default",
Text = "Default token",
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;
}
@@ -1091,7 +1057,7 @@ public sealed partial class MainWindow : Window
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(
_settings, _tokenVault, _accountsStore, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log)
_settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log)
{
XamlRoot = Content.XamlRoot,
};
@@ -1135,28 +1101,9 @@ public sealed partial class MainWindow : Window
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
=> await OpenRepositoryPageAsync("");
/// <summary>Help > Submit an issue: GitHub's new-issue chooser (bug report / feature request templates).</summary>
private async void SubmitIssueMenuItem_Click(object sender, RoutedEventArgs e)
=> await OpenRepositoryPageAsync("/issues/new/choose");
/// <summary>Help > Community support: the repository's GitHub Discussions.</summary>
private async void CommunitySupportMenuItem_Click(object sender, RoutedEventArgs e)
=> await OpenRepositoryPageAsync("/discussions");
/// <summary>Opens a page of the repository in the default browser; the log names the page, never a token.</summary>
private async Task OpenRepositoryPageAsync(string path)
{
string url = RepoUrl + path;
_log.Info($"Opening {url} in the browser.");
await Windows.System.Launcher.LaunchUriAsync(new Uri(url));
_log.Info($"Opening {RepoUrl} in the browser.");
await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl));
}
-9
View File
@@ -4,7 +4,6 @@
*/
using System;
using gclo.ViewModels;
using Velopack;
@@ -26,14 +25,6 @@ public static class Program
// Must be first: handles Velopack install/update/uninstall hooks and may exit.
VelopackApp.Build().Run();
// The dev channel is packed as its own app (id gclo-dev) so it installs beside
// the stable one; it keeps its own data root and says so in the title (#114).
// Local and CLI runs have no Velopack app id and stay on the stable paths.
if(string.Equals(Velopack.Locators.VelopackLocator.Current?.AppId, "gclo-dev", StringComparison.Ordinal))
{
GcloPaths.Edition = "dev";
}
// The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim.
global::WinRT.ComWrappersSupport.InitializeComWrappers();
global::Microsoft.UI.Xaml.Application.Start((p) =>
+4 -17
View File
@@ -34,33 +34,20 @@
x:Name="DefaultTokenBox"
Header="Default GitHub token"
AutomationProperties.AutomationId="SettingsDefaultTokenBox"
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>
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">
<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="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
ToolTipService.ToolTip="Deletes the saved default token from Windows Credential Manager when you press Save"
Click="RemoveTokenButton_Click" />
</Grid>
</StackPanel>
<NumberBox
x:Name="ConcurrencyBox"
Header="Default parallelism"
+28 -53
View File
@@ -5,7 +5,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
@@ -43,12 +42,8 @@ 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>
/// 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;
/// <summary>Set by the Remove link; the deletion happens on Save.</summary>
private bool _removeSavedToken;
@@ -57,15 +52,10 @@ 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, AccountsStore accounts, Func<nint> windowHandleProvider, IActivityLog? log = null)
public SettingsDialog(AppSettings settings, ITokenVault vault, 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;
@@ -84,11 +74,9 @@ public sealed partial class SettingsDialog : ContentDialog
SplashToggle.IsOn = settings.ShowSplashScreen;
SplashDurationBox.Value = settings.SplashMilliseconds;
// 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();
// 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);
}
@@ -157,20 +145,15 @@ public sealed partial class SettingsDialog : ContentDialog
_settings.Save();
string typed = DefaultTokenBox.Password;
switch(_tokenState.Plan(typed))
if(typed.Length > 0)
{
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;
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
changes.Add("default token stored");
}
else if(_removeSavedToken)
{
_vault.Delete(AppSettings.DefaultTokenVaultId);
changes.Add("default token removed");
}
_log.Info(changes.Count == 0
@@ -180,30 +163,22 @@ 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()
{
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;
_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;
}
+1 -9
View File
@@ -19,18 +19,12 @@
<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">112</GridLength>
<GridLength x:Key="ArchivedColumnWidth">96</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. -->
@@ -549,7 +543,6 @@
<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"
@@ -560,7 +553,6 @@
<CheckBox
MinWidth="0"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />