Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 198a56213b ci(release): verify the CLI zip under the edition's app id (#117)
On v1.1.0-beta.1 every job passed and the release is fully signed
(verified locally: 289 files, 0 failures, attestation OK), but
`verify-signatures` looked for `gclo-cli-win-x64-…zip` while the dev
edition ships `gclo-dev-cli-win-x64-…zip`. The job now uses the app id
for the CLI name too. RELEASING.md corrected and notes that both
editions share Credential Manager entries.

Closes #116.

Tested: actionlint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 19:12:03 -05:00
james.maesandClaude Fable 5.1 d77049eb3c feat(release): pack the dev channel as its own app so it installs beside stable (#115)
Prereleases now pack as Velopack app id **gclo-dev** while stable stays
**gclo**, so a dev build installs beside the stable app: separate
install folder, Start menu entry, update feed and data root
(`%LOCALAPPDATA%\KofTwentyTwo\gclo-dev`), with `(dev)` in the window
title.

- `Program.cs` reads the installed app id from `VelopackLocator.Current`
and sets `GcloPaths.Edition`; `GcloPaths.DefaultDataRoot` follows it
(pure mapping unit-tested). Local and CLI runs stay on the stable paths.
- `release.yml`: `app-id` derived from the tag (prerelease →
`gclo-dev`); `verify-signatures` downloads the matching asset names
(`gclo-dev-dev-Setup.exe` …).
- `docs/RELEASING.md`: the split, the asset names, and that dev installs
made before the split (app id `gclo`, up to 1.0.2-beta.3) must be
uninstalled or replaced by the stable app.

Closes #114 (part of #62).

Tested: build 0 warnings, format clean, actionlint clean, Engine tests
480/480 with 100% coverage on Engine and ViewModels (the
real-Credential-Manager round-trip test flaked once in a parallel run
and passes on its own and on re-run).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:47:26 -05:00
james.maesandClaude Fable 5.1 dda0a22287 feat(help): Submit an issue and Community support links (#113)
Help menu gains **Submit an issue…** (GitHub's new-issue chooser, which
offers the repository's bug and feature templates) and **Community
support…** (GitHub Discussions, enabled on the repository), opened in
the default browser like *View on GitHub*. The three items are separated
from *Check for updates…*; each has an automation id and a tooltip.

Closes #108.

Tested: build 0 warnings, format clean, FlaUI suite 11/11 (one
name-filter test flaked once and passes on its own).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:35:53 -05:00
james.maesandClaude Fable 5.1 f4014ff5c9 feat(settings): show a saved default token as a mask and say what Save will do (#112)
## What

Step 3 of the approved [UX plan](docs/plans/default-token-ux.md),
stacked on #106 (step 2) and #103 (step 1).

- `SettingsTokenState` (ViewModels, fully tested): the box's initial
value (a fixed mask when a token is saved, never the token), what Save
does for a given box value (keep / store / remove), whether the Remove
link applies, and the caption text, which names who depends on the token
and what Save will do.
- `SettingsDialog`: shows the mask when a token is saved; the caption
follows every edit; clearing the box or pressing *Remove saved token*
removes on Save; a removal that breaks accounts turns the caption
critical and names them, so Save is an informed confirmation (inline,
not a second dialog: a `ContentDialog` cannot open another and the
dialog guard allows one at a time; the plan is updated to say so).
Replacing the token says the dependent accounts rotate with it. The
dialog now takes the `AccountsStore` to count and name them.

Closes #101 (part of #62).

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 475/475 (+16 for `SettingsTokenState`); line
coverage 100% on `gclo.Engine` and `gclo.ViewModels`.
- FlaUI suite run locally against the rebuilt app (result in the PR
comments).

Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:29:55 -05:00
james.maesandClaude Fable 5.1 472201f0cf feat(wizard): offer the default token when adding or editing an account (#111)
## What

Step 2 of the approved [UX plan](docs/plans/default-token-ux.md),
stacked on #103 (step 1). Part of #102.

- **Wizard step 2** (AccountWizardViewModel, AccountWizardDialog): when
a default token is saved, a radio group offers *Use the default token
from Settings* (preselected for a new account) or *Use a different token
for this account*; the token box is enabled only for the latter. The
caption says who else uses the default token and, on an edit switching
to it, that the account's own token will be removed. Validation fetches
the default token for that one call only; it is never loaded into the
box. Missing default token → a Settings-specific message. Edit mode
starts from the account's current source and can switch either way. The
seeded wizard preselects the default when the Quick Sync token is the
default token (compared in memory).
- **Pane**: default-token accounts show a *Default token* tag next to
the name (and in the automation name), refreshed on edit.
- **Sync All**: a default-token account with no default token saved is
skipped with the reason "no default token is saved (Settings)" before
any GitHub call.
- MainWindow passes the default-token accessor to both wizard
constructors.

Closes #105.

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 459/459 (+12: wizard choice/validation/save in
both modes, seeded preselection, caption counts, Sync All skip and run);
line coverage 100% on `gclo.Engine` and `gclo.ViewModels`.
- FlaUI suite run locally against the rebuilt app (result in the PR
comments).

Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:24:44 -05:00
james.maesandClaude Fable 5.1 b9c0faa41c feat(accounts): reference the default token instead of copying it (#110)
## What

Step 1 of the approved [UX plan](docs/plans/default-token-ux.md) (#101,
#102): the model, the resolver, the store and the CLI. No desktop UI
change yet (step 2: the wizard; step 3: Settings).

- `Account.TokenSource` (`Own` | `Default`): `Default` accounts
**reference** the default token saved in Settings and never copy it, so
rotating it rotates every such account. `Own` is the on-disk default and
is omitted from `accounts.json`, so existing files and vault entries
stay byte-identical (round-trip tests).
- `AccountTokenResolver`: the one place that turns an account into its
token, with a source-specific "missing" description. Used by
`WorkspaceViewModel`, Sync All (through the workspace) and `gclo sync
--account`.
- `AccountsStore`: a `Default` account is saved with no vault entry (a
token for it is rejected); switching to `Default` removes the own entry
vault-first with the same compensation as a delete (consistency tests
for the restore and the double-failure paths);
`CountUsingDefaultToken()` for Settings.
- CLI: `accounts add|edit --use-default-token` (fatal when no default
token is saved; exclusive with the other token options), `accounts list`
token column + `tokenSource` JSON field, `sync --account` errors name
the source and the fix. Help text and `docs/CLI.md` updated.
- Threat model T5 + revision row.

Closes #104 (part of #102, #62).

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 447/447 (+22), `gclo.Cli.Tests` 142/142 (+13);
line coverage 100% on `gclo.Engine`, `gclo.ViewModels` and `gclo`.

Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:17:33 -05:00
james.maesandClaude Fable 5.1 935430c84a ci(release): run verify-signatures in the release environment (#100)
The job checks the publisher against TRUSTED_SIGNING_SUBJECT, a
elease-environment variable, but did not run in that environment and so
read an empty value (v1.0.2-beta.3, run 38087007507). Adds nvironment:
release. The release itself was fully signed: verified locally with
signtool on all 289 executables and libraries across Setup, the portable
zip and the CLI zip, 0 failures.

Closes #99 (part of #62).

Tested: actionlint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:10:40 -05:00
42 changed files with 2223 additions and 124 deletions

No files matched your search

+12 -5
View File
@@ -153,7 +153,10 @@ jobs:
with:
solution: gclo.slnx
app-project: gclo/gclo.csproj
app-id: gclo
# 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' }}
main-exe: gclo.exe
icon: gclo/Assets/Brand/gclo.ico
platform: x64
@@ -184,12 +187,16 @@ 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
@@ -199,10 +206,10 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
New-Item -ItemType Directory -Force assets | Out-Null
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"
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"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
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
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
- name: signtool verify /pa /all on every shipped executable
shell: pwsh
@@ -214,7 +221,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/gclo-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
$files = @(Get-Item assets/$env:K22_APP_ID-$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)
+25 -9
View File
@@ -244,23 +244,26 @@ the app's account wizard, and use them from either.
### `gclo accounts` / `gclo accounts list`
Lists the saved accounts, one per line, in aligned columns: name, organization,
target root, and last sync time (local time, `never` when the account has not
completed a sync yet):
target root, token source (`own` or `default`), and last sync time (local time,
`never` when the account has not completed a sync yet):
```
work contoso C:\src\contoso 2026-07-04 09:12
personal octocat D:\mirror never
work contoso C:\src\contoso own 2026-07-04 09:12
personal octocat D:\mirror default never
```
With `--json` it prints a single-line JSON array with everything a script needs
to reason about a run; `lastSync` is the UTC timestamp of the last completed
sync and `lastSyncSummary` its summary line, both `null` until the first sync:
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
`tokenSource` is `own` or `default`:
```json
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44."}]
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
```
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`).
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
for an `own` account; a `default` account has no entry of its own and resolves
to the default token saved in the desktop app's Settings whenever it syncs.
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
hint goes to stderr; the exit code is still 0.
@@ -277,6 +280,16 @@ variable) and is written to Windows Credential Manager — never put it on the
command line. A name that another account already uses (case-insensitive)
exits with code 2.
With `--use-default-token` instead of a token option, the account holds no
token of its own: it resolves to the default token saved in the desktop app's
Settings every time it syncs, so replacing that token in Settings updates every
such account at once. The command fails (exit 2) when no default token is saved,
because the account could never sync; the CLI cannot set the default token.
```powershell
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
```
### `gclo accounts edit`
```powershell
@@ -286,8 +299,11 @@ gclo accounts edit --name work --rename "work (contoso)"
```
`--name` picks the account; every other option replaces just that setting.
Without a token option the stored token is left untouched. Editing with
nothing to change is a usage error.
Without a token option the token (and its source) is left untouched. A
`--token-*` option gives the account a token of its own (also switching a
`default` account to `own`); `--use-default-token` switches it to the default
token and removes its own entry from Credential Manager. Editing with nothing
to change is a usage error.
### `gclo accounts remove`
+18
View File
@@ -23,6 +23,24 @@ 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
+177
View File
@@ -0,0 +1,177 @@
# UX plan: the default token and account tokens
> **Status: approved** by the owner on 2026-10-10; step 1 is #104. Delivers #101
> (Settings shows a saved default token as a masked value) and #102 (Add account
> uses the default token by reference). Tracker of record: GitHub Issues; this
> document is the design.
## The problem, as the owner sees it
1. In **Settings**, a saved default token leaves the token box empty. An empty box
reads as "nothing saved", even with the caption underneath.
2. **Add account** always demands a token on step 2, even when a default token is
saved. Every account then holds its own copy, so rotating the default token in
Settings does nothing for existing accounts: each keeps the old copy until it
fails.
## Design decisions
- **An account references the default token; it never copies it.** A new field
`TokenSource` on the account record: `Default` (use whatever the default token
is right now) or `Own` (this account's own vault entry, today's behaviour).
Rotating the default token in Settings rotates every `Default` account at once.
`Own` accounts are untouched by the default, as today.
- **One resolver, used everywhere.** `AccountTokenResolver.Resolve(account)` in
`gclo.ViewModels` returns the token to use for an account (`Default` → vault
entry `DefaultTokenVaultId`; `Own` → vault entry `account.Id`) plus *why* it is
missing when it is. The desktop workspace, Sync All, the wizard's validation
and the CLI's `sync --account` all go through it. No consumer reads the vault
for an account directly any more.
- **The token itself is never shown.** The Settings box shows a fixed mask when a
token is saved; the wizard never loads a token into a box; the reveal button
of a `PasswordBox` can only ever reveal what the user typed in this session.
- **Backward compatible on disk.** `TokenSource` defaults to `Own` when absent, so
every existing `accounts.json` and every existing vault entry keeps working.
1.0.x builds reading a newer file ignore the field (System.Text.Json default).
- **Deleting the default token is a guarded action** when `Default` accounts
exist: Settings says which accounts stop working and asks for confirmation.
## Settings dialog (#101)
| State | Token box | Caption | Remove link |
| --- | --- | --- | --- |
| No default token saved | empty, placeholder `ghp_…` | "No default token saved. Quick Sync will ask for one each launch." | hidden |
| Default token saved, untouched | shows the mask `••••••••••••••••` (a constant, never the token) | "A default token is saved and used by Quick Sync and *N* account(s). Type to replace it." | visible |
| User typed a new value | the typed value (masked) | "The default token will be replaced on Save." | visible |
| 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.
Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the
mask is announced as the field's value by the password pattern (dots), the
caption is a live region, and the Remove link's text names the consequence.
## Add account / Edit account wizard (#102)
Step 2 ("Access token") becomes a choice, shown only when a default token is
saved; without one the step looks exactly as today.
```text
Step 2 of 4: Access token
( ) Use the default token from Settings ← selected by default for a new account
Used by Quick Sync and N other account(s). Replacing it in Settings updates
this account too.
( ) Use a different token for this account
[ Personal access token ..................... ] (enabled only when selected)
How to create a token
Next validates the chosen token and lists the organizations it can see.
```
- **New account:** "Use the default token" is preselected. Next validates the
*default* token through the resolver and lists its organizations. On save the
account is written with `TokenSource = Default` and **no vault entry** of its
own. Choosing "a different token" requires typing one; save writes
`TokenSource = Own` and the entry, as today.
- **Edit account:** the radio reflects the current source. Switching `Own →
Default` deletes the account's own vault entry on save (confirmed in the
caption: "This account's own token will be removed from Credential Manager").
Switching `Default → Own` requires a token. Staying on `Own` with the box
empty keeps the stored token, as today.
- **Default token missing** (removed after the account was created): step 2
shows "The default token is no longer saved. Save one in Settings, or use a
different token for this account." and Next stays put.
- **Seeded wizard** ("save this Quick Sync connection as an account"): if the
typed Quick Sync token equals the saved default token, the wizard preselects
"Use the default token"; otherwise "a different token" with the typed value.
(Comparison happens in memory in the view model; nothing is logged.)
## Where the account's token shows up elsewhere
- **Navigation pane / account header:** `Default` accounts get a small
"Default token" tag (text, not just an icon) in the account's header, so the
dependency on Settings is visible where the account is used.
- **Account workspace:** the token box is prefilled from the resolver as today.
For a `Default` account its header reads "Using the default token"; editing the
box affects this session only, exactly as for Quick Sync.
- **Sync All:** a `Default` account whose default token is missing is skipped
with the result "No default token saved (Settings)" instead of a generic auth
error.
- **Error messages:** every "no token" message names the source
("the default token in Settings" or "this account's own token") and the fix.
## CLI parity (`gclo accounts`, `gclo sync --account`)
- `accounts add … --use-default-token` creates a `Default` account; the existing
`--token-env | --token-file | --token-stdin` create `Own` accounts. Exactly one
of the four is required (today: one of the three).
- `accounts edit --name X --use-default-token` switches to `Default` and removes
the own entry; a `--token-*` option switches to `Own`.
- `accounts list` gains a `token` column: `default` or `own`; the JSON output
gains `"tokenSource"`.
- `sync --account X` resolves through the resolver; its "no token" error names
the source and the fix (`gclo accounts edit --name X --token-stdin`, or saving
a default token in the desktop app's Settings; the CLI gets no way to *set*
the default token in this change).
- The default token stays a desktop-app concept to set, but the CLI reads it,
so a headless mirror can use `Default` accounts created on the same machine.
## Data model and storage
- `Account.TokenSource` (`enum TokenSource { Own, Default }`), serialized as a
string, default `Own`, `[JsonIgnoreCondition.WhenWritingDefault]` so existing
files stay byte-identical until an account actually uses the default.
- `AccountsStore.Save(account, token)` keeps its contract; a `Default` account is
always saved with `token: null`, and switching to `Default` removes the own
entry inside the same vault-first/compensate sequence the store already has.
- `AccountsStore.CountUsingDefaultToken()` feeds the Settings captions.
- Vault scoping (#60) is unchanged: `DefaultTokenVaultId` already lives in the
same scope as the accounts.
## Security review (threat model T5, T6)
- No new place holds a token: the mask is a constant; the resolver returns the
token to the same callers that already received it; nothing is logged.
- A `Default` account widens the blast radius of the default token to every
such account by design; the Settings warning on removal and the "Default
token" tag make that dependency visible. Rotation becomes a single action,
which is the point.
- The CLI still has no `--token <value>`; `--use-default-token` carries no
secret on the command line.
## Tests
- ViewModels (100% line coverage stays enforced): resolver (both sources,
missing cases), wizard step 2 for new/edit/seeded in both sources and the
missing-default error, store save/switch/compensation with `TokenSource`,
settings captions and remove-with-dependents counts (through a small
`SettingsTokenState` view model so the dialog has no logic of its own),
Sync All skip reason.
- CLI (100%): `--use-default-token` on add/edit, mutual exclusion with
`--token-*`, list column and JSON field, sync resolution error text.
- FlaUI: Settings shows the mask when a token is saved and an empty box when
not; Add account shows the radio only when a default token exists, and the
token box is disabled while "Use the default token" is selected.
- Serialization round-trip: an `accounts.json` without the field loads as `Own`
and re-saves without the field.
## Delivery
One issue per layer, one PR each, in this order, every PR green on all gates:
1. **#102a – model + resolver + store + CLI** (no UI change yet; `Default`
accounts can only be created by the CLI so far). Threat model updated.
2. **#102b – wizard + pane tag + Sync All reason** (desktop).
3. **#101 – Settings dialog** (mask, captions, guarded removal), last because its
"used by N accounts" count depends on step 1.
Ships in the next minor (1.1.0): it adds a field to the account record and a
CLI option, which is a feature, not a fix; 1.0.2 stays the pipeline/data-root
fix release.
+2 -1
View File
@@ -87,7 +87,7 @@ flowchart LR
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated |
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated |
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
| T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) |
| T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated |
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
@@ -114,3 +114,4 @@ flowchart LR
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T11: the packaging PR is opened with a short-lived release GitHub App token (contents + pull requests on this repository, no bypass) instead of a long-lived PAT; the commit is GitHub-signed (#85) |
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) |
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) |
| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) |
+138
View File
@@ -234,6 +234,144 @@ public sealed class AccountsCommandTests : IDisposable
// ---------------------------------------------------------------- default token (#102)
[Fact]
public void Add_UseDefaultToken_CreatesAnAccountWithoutAnOwnEntry()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
Assert.Equal(0, code);
Account saved = Store.FindByName("shared")!;
Assert.True(saved.UsesDefaultToken);
Assert.Null(_vault.TryRetrieve(saved.Id));
Assert.Contains("uses the default token", console.Out);
}
[Fact]
public void Add_UseDefaultToken_WithoutASavedDefault_IsAFatalError()
{
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()));
Assert.Contains("No default token is saved", ex.Message);
Assert.Contains("Settings", ex.Message);
Assert.Empty(Store.GetAll());
}
[Fact]
public void Add_UseDefaultToken_WithAnotherTokenOption_IsAUsageError()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
using IDisposable _ = TokenEnv("ghp_x");
Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token", "--token-env", EnvVar],
Open, new NullLog()));
Assert.Empty(Store.GetAll());
}
[Fact]
public void Edit_UseDefaultToken_SwitchesTheAccount_AndRemovesItsOwnToken()
{
Seed("work", "acme", @"C:\a");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Guid id = Store.FindByName("work")!.Id;
Assert.Equal("tok-work", _vault.TryRetrieve(id));
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog());
Assert.Equal(0, code);
Assert.True(Store.FindByName("work")!.UsesDefaultToken);
Assert.Null(_vault.TryRetrieve(id));
Assert.Contains("now uses the default token", console.Out);
}
[Fact]
public void Edit_UseDefaultToken_WithoutASavedDefault_IsAFatalError_AndKeepsTheOwnToken()
{
Seed("work", "acme", @"C:\a");
Guid id = Store.FindByName("work")!.Id;
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog()));
Assert.False(Store.FindByName("work")!.UsesDefaultToken);
Assert.Equal("tok-work", _vault.TryRetrieve(id));
}
[Fact]
public void Edit_TokenOption_SwitchesADefaultTokenAccountToItsOwnToken()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
using IDisposable _ = TokenEnv("ghp_mine");
int code = AccountsCommand.Run(["edit", "--name", "shared", "--token-env", EnvVar], Open, new NullLog());
Assert.Equal(0, code);
Account updated = Store.FindByName("shared")!;
Assert.False(updated.UsesDefaultToken);
Assert.Equal("ghp_mine", _vault.TryRetrieve(updated.Id));
}
[Fact]
public void Edit_OtherSettings_LeaveTheTokenSourceAlone()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
AccountsCommand.Run(["edit", "--name", "shared", "--parallel", "3"], Open, new NullLog());
Account updated = Store.FindByName("shared")!;
Assert.True(updated.UsesDefaultToken);
Assert.Equal(3, updated.MaxConcurrency);
}
[Fact]
public void List_ShowsTheTokenSource_InColumnsAndJson()
{
Seed("work", "acme", @"C:\a");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\b", "--use-default-token"], Open, new NullLog());
using(var console = new ConsoleCapture())
{
Assert.Equal(0, AccountsCommand.Run([], Open, new NullLog()));
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(lines, l => l.StartsWith("shared", StringComparison.Ordinal) && l.Contains(" default ", StringComparison.Ordinal));
Assert.Contains(lines, l => l.StartsWith("work", StringComparison.Ordinal) && l.Contains(" own ", StringComparison.Ordinal));
}
using(var console = new ConsoleCapture())
{
Assert.Equal(0, AccountsCommand.Run(["--json"], Open, new NullLog()));
Assert.Contains("\"name\":\"shared\"", console.Out);
Assert.Contains("\"tokenSource\":\"default\"", console.Out);
Assert.Contains("\"tokenSource\":\"own\"", console.Out);
}
}
[Fact]
public void Add_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
+4
View File
@@ -91,12 +91,16 @@ internal sealed class FakeRepoLister : IRepositoryLister
public Exception? Throw { get; set; }
/// <summary>The token of the most recent call, so a test can check which source was used.</summary>
public string? LastToken { get; private set; }
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
LastToken = token;
return Throw is not null
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
: Task.FromResult(Result);
+34
View File
@@ -579,6 +579,40 @@ public sealed class SyncCommandTests : IDisposable
[Fact]
public async Task Account_UsingTheDefaultToken_SyncsWithIt()
{
Account account = Seed("shared", "acme");
_vault.Delete(account.Id);
Store.Save(account with { TokenSource = TokenSource.Default }, null);
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
var lister = new FakeRepoLister { Result = [Repo("a")] };
int code = await Run(lister, new FakeGit(), "--account", "shared");
Assert.Equal(0, code);
Assert.Equal("ghp_default", lister.LastToken);
}
[Fact]
public async Task Account_UsingTheDefaultToken_WithoutOne_NamesSettingsInTheError()
{
Account account = Seed("shared", "acme");
_vault.Delete(account.Id);
Store.Save(account with { TokenSource = TokenSource.Default }, null);
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "shared"));
Assert.Contains("no default token is saved", ex.Message);
Assert.Contains("Settings", ex.Message);
Assert.DoesNotContain("gclo:account:", ex.Message);
}
[Fact]
public async Task Account_ExplicitTokenOption_OverridesVault()
{
+31
View File
@@ -26,6 +26,37 @@ public sealed class TokenOptionsTests
[Fact]
public void UseDefaultToken_IsAnExplicitSource_ThatResolvesToNoValue()
{
TokenOptions options = Parse("--use-default-token");
Assert.True(options.HasExplicitSource);
Assert.True(options.UseDefault);
CliUsageException ex = Assert.Throws<CliUsageException>(() => options.Resolve());
Assert.Contains("--use-default-token", ex.Message);
Assert.Contains("accounts add", ex.Message);
}
[Fact]
public void UseDefaultToken_CombinedWithAnotherSource_IsAUsageError()
{
CliUsageException ex = Assert.Throws<CliUsageException>(() => Parse("--use-default-token", "--token-stdin").Resolve());
Assert.Contains("only one of", ex.Message);
Assert.Contains("--use-default-token", ex.Message);
}
[Fact]
public void UseDefaultToken_RejectsAValue()
=> Assert.Throws<CliUsageException>(() => Parse("--use-default-token=yes"));
[Fact]
public void TryConsume_NonTokenOption_ReturnsFalse()
{
+2 -1
View File
@@ -21,4 +21,5 @@ internal sealed record AccountSummary(
bool CreateOrgSubfolder,
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary);
string? LastSyncSummary,
string TokenSource);
+62 -14
View File
@@ -32,12 +32,14 @@ internal static class AccountsCommand
list (default)
Prints the accounts, one per line, in aligned columns: name,
organization, target root, and last sync time (local time, 'never'
when the account has not completed a sync yet).
organization, target root, token source ('own' or 'default'), and last
sync time (local time, 'never' when the account has not completed a
sync yet).
--json Print a single-line JSON array instead:
[{"id":"...","name":"...","description":"...","organization":"...",
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null}]
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null,
"tokenSource":"own"|"default"}]
add
--name <name> Display name; must be unique (case-insensitive).
@@ -49,6 +51,10 @@ internal static class AccountsCommand
--token-env <VAR> | --token-file <path> | --token-stdin
The token to store (default: the GITHUB_TOKEN
environment variable). Never pass it as an argument.
--use-default-token Instead of a token of its own, the account uses
the default token saved in the desktop app's
Settings, read whenever it syncs, so replacing
the default token there updates this account too.
edit
--name <name> The account to change (required).
@@ -58,8 +64,11 @@ internal static class AccountsCommand
--org-subfolder | --no-org-subfolder
Turn the organization subfolder on or off.
--token-env | --token-file | --token-stdin
Replace the stored token; without a token option
the stored token is left untouched.
Replace the stored token (and switch an account
on the default token to a token of its own);
without a token option the token is left untouched.
--use-default-token Switch the account to the default token; its own
token is removed from Windows Credential Manager.
remove
--name <name> Deletes the account's settings and its stored
@@ -137,7 +146,7 @@ internal static class AccountsCommand
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
@@ -158,7 +167,7 @@ internal static class AccountsCommand
{
Console.Out.WriteLine(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
}
return ExitCodes.Success;
}
@@ -234,11 +243,14 @@ internal static class AccountsCommand
throw new CliUsageException("--target is required.");
}
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
try
{
string token = tokenOptions.Resolve();
(AccountsStore store, _) = open(log);
// The token (if the account gets one of its own) is read before the store
// opens, so a bad token option fails without touching accounts.json.
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve();
(AccountsStore store, ITokenVault vault) = open(log);
RequireDefaultTokenIfUsed(tokenOptions, vault);
var account = new Account
{
Id = Guid.NewGuid(),
@@ -248,9 +260,10 @@ internal static class AccountsCommand
TargetRoot = target.Trim(),
CreateOrgSubfolder = orgSubfolder,
MaxConcurrency = parallel,
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
};
SaveOrFail(store, account, token);
Console.Out.WriteLine($"Account '{account.Name}' added.");
Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
return ExitCodes.Success;
}
catch(Exception ex)
@@ -332,10 +345,17 @@ internal static class AccountsCommand
log.Info($"accounts edit started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
(AccountsStore store, ITokenVault vault) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
RequireDefaultTokenIfUsed(tokenOptions, vault);
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null;
// A token option decides the source: --use-default-token switches to the
// default token (and drops the own entry); --token-* gives the account a
// token of its own; neither leaves the source and the token untouched.
TokenSource source = tokenOptions.UseDefault
? TokenSource.Default
: token is null ? existing.TokenSource : TokenSource.Own;
Account updated = existing with
{
Name = rename?.Trim() ?? existing.Name,
@@ -344,9 +364,13 @@ internal static class AccountsCommand
Description = description?.Trim() ?? existing.Description,
MaxConcurrency = parallel ?? existing.MaxConcurrency,
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
TokenSource = source,
};
SaveOrFail(store, updated, token);
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
string tokenNote = tokenOptions.UseDefault
? " (now uses the default token)."
: token is null ? "." : " (token replaced).";
Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote);
return ExitCodes.Success;
}
catch(Exception ex)
@@ -405,6 +429,30 @@ internal static class AccountsCommand
// ---------------------------------------------------------------- shared
/// <summary>'own' or 'default': the token source as the list column and the JSON field show it.</summary>
private static string TokenSourceLabel(Account account) => account.UsesDefaultToken ? "default" : "own";
/// <summary>
/// --use-default-token is only useful when a default token exists: without one
/// the account could never sync, so the command fails up front and says where
/// the default token is set (the CLI has no way to set it).
/// </summary>
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault)
{
tokenOptions.EnsureAtMostOneSource();
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken)
{
throw new CliErrorException(
"No default token is saved, so the account could not sync. Save one in the desktop app "
+ "(Settings > Default GitHub token), or give the account a token of its own with --token-env, "
+ "--token-file, or --token-stdin.");
}
}
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
private static void SaveOrFail(AccountsStore store, Account account, string? token)
{
+9 -6
View File
@@ -468,14 +468,17 @@ internal static class SyncCommand
}
// vault is non-null whenever account is: both come from AccountsCommand.Open.
string? token = vault!.TryRetrieve(account.Id);
if(string.IsNullOrEmpty(token))
string? token = new AccountTokenResolver(vault!).TryResolve(account);
if(token is null)
{
string fix = account.UsesDefaultToken
? "Save a default token in the desktop app's Settings, switch the account to its own token with "
+ "'gclo accounts edit --name <name> --token-stdin',"
: "Re-enter the token with 'gclo accounts edit --name <name> --token-stdin' or in the desktop app's "
+ "account wizard, restore the credential entry manually,";
throw new CliErrorException(
$"Account '{account.Name}' has no token in Windows Credential Manager "
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with "
+ "'gclo accounts edit --name <name> --token-stdin' or in the desktop app's account wizard, "
+ "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin.");
$"Account '{account.Name}' {AccountTokenResolver.MissingDescription(account)}. {fix} "
+ "or pass a token with --token-env, --token-file, or --token-stdin.");
}
return token;
}
+35 -7
View File
@@ -25,14 +25,25 @@ internal sealed class TokenOptions
private bool _useStdin;
private bool _useDefault;
/// <summary>
/// True when any token option (--token-env, --token-file, --token-stdin) was
/// given. 'gclo sync --account' uses this to let an explicit token source
/// override the account's vault-stored token.
/// True when any token option (--token-env, --token-file, --token-stdin,
/// --use-default-token) was given. 'gclo sync --account' uses this to let an
/// explicit token source override the account's stored token.
/// </summary>
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin || _useDefault;
/// <summary>
/// --use-default-token: the account resolves to the default token saved in the
/// desktop app's Settings instead of holding a token of its own (accounts add
/// and edit only). <see cref="Resolve"/> rejects it, as it names no token value.
/// </summary>
public bool UseDefault => _useDefault;
@@ -51,6 +62,10 @@ internal sealed class TokenOptions
reader.RejectValue();
_useStdin = true;
return true;
case "--use-default-token":
reader.RejectValue();
_useDefault = true;
return true;
default:
return false;
}
@@ -58,6 +73,18 @@ internal sealed class TokenOptions
/// <summary>Usage error when more than one token option was given; a no-op otherwise.</summary>
public void EnsureAtMostOneSource()
{
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0) + (_useDefault ? 1 : 0);
if(sources > 1)
{
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin, --use-default-token.");
}
}
/// <summary>
/// Resolves the token from the single configured source, or from the
/// <see cref="DefaultVariable"/> environment variable when none was given.
@@ -65,10 +92,11 @@ internal sealed class TokenOptions
/// </summary>
public string Resolve()
{
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0);
if(sources > 1)
EnsureAtMostOneSource();
if(_useDefault)
{
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin.");
throw new CliUsageException(
"--use-default-token names no token value; it is for 'gclo accounts add' and 'gclo accounts edit' only.");
}
if(_useStdin)
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="AccountTokenResolver"/>: an account's own entry for <see cref="TokenSource.Own"/>,
/// the default token for <see cref="TokenSource.Default"/>, null (with a source-specific
/// description) when the source holds nothing.
/// </summary>
public sealed class AccountTokenResolverTests
{
private readonly InMemoryVault _vault = new();
private static Account MakeAccount(TokenSource source) => new()
{
Id = Guid.NewGuid(),
Name = "work",
Organization = "acme",
TargetRoot = @"C:\repos",
TokenSource = source,
};
[Fact]
public void NullVault_Throws()
=> Assert.Throws<ArgumentNullException>(() => new AccountTokenResolver(null!));
[Fact]
public void NullAccount_Throws()
{
var resolver = new AccountTokenResolver(_vault);
Assert.Throws<ArgumentNullException>(() => resolver.TryResolve(null!));
Assert.Throws<ArgumentNullException>(() => AccountTokenResolver.MissingDescription(null!));
}
[Fact]
public void OwnAccount_ResolvesItsOwnEntry_NotTheDefault()
{
Account account = MakeAccount(TokenSource.Own);
_vault.Store(account.Id, "ghp_own");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Assert.Equal("ghp_own", new AccountTokenResolver(_vault).TryResolve(account));
}
[Fact]
public void DefaultAccount_ResolvesTheDefaultToken_EvenWhenAnOwnEntryLingers()
{
Account account = MakeAccount(TokenSource.Default);
_vault.Store(account.Id, "ghp_stale");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Assert.Equal("ghp_default", new AccountTokenResolver(_vault).TryResolve(account));
}
[Fact]
public void DefaultAccount_FollowsRotationOfTheDefaultToken()
{
Account account = MakeAccount(TokenSource.Default);
var resolver = new AccountTokenResolver(_vault);
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_first");
Assert.Equal("ghp_first", resolver.TryResolve(account));
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_rotated");
Assert.Equal("ghp_rotated", resolver.TryResolve(account));
}
[Theory]
[InlineData(TokenSource.Own)]
[InlineData(TokenSource.Default)]
public void MissingSource_ResolvesNull(TokenSource source)
{
Account account = MakeAccount(source);
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
}
[Fact]
public void EmptyStoredValue_CountsAsMissing()
{
Account account = MakeAccount(TokenSource.Own);
_vault.Store(account.Id, "");
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
}
[Fact]
public void HasDefaultToken_ReflectsTheVault()
{
var resolver = new AccountTokenResolver(_vault);
Assert.False(resolver.HasDefaultToken);
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Assert.True(resolver.HasDefaultToken);
}
[Fact]
public void MissingDescription_NamesTheSource_AndNeverATokenValue()
{
Account own = MakeAccount(TokenSource.Own);
Account byDefault = MakeAccount(TokenSource.Default);
string ownText = AccountTokenResolver.MissingDescription(own);
string defaultText = AccountTokenResolver.MissingDescription(byDefault);
Assert.Contains($"gclo:account:{own.Id:N}", ownText);
Assert.Contains("Settings", defaultText);
Assert.DoesNotContain("ghp", ownText + defaultText);
}
}
@@ -390,6 +390,224 @@ 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 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()
{
_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()
{
@@ -388,6 +388,67 @@ public sealed class AccountsStoreConsistencyTests : IDisposable
/// Delete can be made to throw (from a given call number on) without changing
/// state — the contract a real secret store keeps on failure.
/// </summary>
// ---------------------------------------------------------------- save: switching to the default token (#102)
[Fact]
public void Save_SwitchToDefaultToken_MetadataWriteFails_RestoresTheOwnToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.ResetCounters();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Equal(1, _vault.DeleteCalls);
Assert.Equal(1, _vault.StoreCalls);
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
Assert.False(Assert.Single(OnDisk()).UsesDefaultToken);
}
[Fact]
public void Save_SwitchToDefaultToken_MetadataWriteFails_WithNoOwnToken_JustPropagates()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.Delete(account.Id); // nothing of its own to put back
BlockMetadataWrites();
_vault.ResetCounters();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
Assert.Equal(0, _vault.DeleteCalls);
Assert.Equal(0, _vault.StoreCalls);
}
[Fact]
public void Save_SwitchToDefaultToken_MetadataWriteFails_AndRestoreFails_ReportsBoth()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.StoreFailure = new InvalidOperationException("vault offline");
_vault.ResetCounters();
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(
() => store.Save(account with { TokenSource = TokenSource.Default }, null));
Assert.Contains("vault offline", ex.Message);
Assert.Contains("lost its own token", ex.Message);
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Contains(_log.Errors, e => e.Message.Contains("could not be restored", StringComparison.Ordinal));
}
private sealed class FaultableVault : ITokenVault
{
private readonly InMemoryVault _inner = new();
+105
View File
@@ -42,6 +42,111 @@ public sealed class AccountsStoreTests : IDisposable
// ---------------------------------------------------------------- default token (#102)
[Fact]
public void Save_DefaultTokenAccount_WritesNoOwnEntry_AndRoundTripsTheSource()
{
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
NewStore().Save(account, token: null);
Assert.Null(_vault.TryRetrieve(account.Id));
Account reloaded = Assert.Single(NewStore().GetAll());
Assert.Equal(TokenSource.Default, reloaded.TokenSource);
Assert.True(reloaded.UsesDefaultToken);
Assert.Contains("\"TokenSource\": \"Default\"", File.ReadAllText(Path.Combine(_root, "accounts.json")));
}
[Fact]
public void Save_OwnTokenAccount_OmitsTheSourceFromTheFile_SoOldFilesStayIdentical()
{
NewStore().Save(MakeAccount("Work"), "ghp_own");
string json = File.ReadAllText(Path.Combine(_root, "accounts.json"));
Assert.DoesNotContain("TokenSource", json);
Assert.Equal(TokenSource.Own, Assert.Single(NewStore().GetAll()).TokenSource);
}
[Fact]
public void Load_FileWithoutTheSourceField_ReadsAsOwn()
{
Directory.CreateDirectory(_root);
var id = Guid.NewGuid();
File.WriteAllText(
Path.Combine(_root, "accounts.json"),
$$"""[{"Id":"{{id}}","Name":"Old","Organization":"acme","TargetRoot":"C:\\repos"}]""");
Account loaded = Assert.Single(NewStore().GetAll());
Assert.Equal(TokenSource.Own, loaded.TokenSource);
Assert.False(loaded.UsesDefaultToken);
}
[Fact]
public void Save_DefaultTokenAccount_WithAToken_IsRejected()
{
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, "ghp_stray"));
Assert.Contains("default token", ex.Message);
Assert.Empty(NewStore().GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
}
[Fact]
public void Save_SwitchingToTheDefaultToken_RemovesTheOwnEntry()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, "ghp_own");
Assert.Equal("ghp_own", _vault.TryRetrieve(account.Id));
store.Save(account with { TokenSource = TokenSource.Default }, token: null);
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.True(Assert.Single(store.GetAll()).UsesDefaultToken);
}
[Fact]
public void Save_SwitchingBackToAnOwnToken_StoresIt()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work") with { TokenSource = TokenSource.Default };
store.Save(account, token: null);
store.Save(account with { TokenSource = TokenSource.Own }, "ghp_mine");
Assert.Equal("ghp_mine", _vault.TryRetrieve(account.Id));
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
}
[Fact]
public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts()
{
AccountsStore store = NewStore();
Assert.Equal(0, store.CountUsingDefaultToken());
store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
store.Save(MakeAccount("C"), "ghp_c");
Assert.Equal(2, store.CountUsingDefaultToken());
Assert.Equal(2, NewStore().CountUsingDefaultToken());
}
// ---------------------------------------------------------------- persistence
[Fact]
+15
View File
@@ -30,6 +30,21 @@ 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()
@@ -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);
}
}
}
@@ -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]
@@ -1793,6 +1793,115 @@ public sealed class WorkspaceViewModelTests : IDisposable
[Fact]
public async Task AccountConstruction_DefaultTokenAccount_UsesTheDefaultToken()
{
var account = new Account
{
Id = Guid.NewGuid(),
Name = "Shared",
Organization = "acme",
TargetRoot = @"C:\repos\shared",
TokenSource = TokenSource.Default,
};
var vault = new InMemoryVault();
vault.Store(AppSettings.DefaultTokenVaultId, "default-token-1234567890");
string? lookedUpToken = null;
_orgs.Handler = (token, _) =>
{
lookedUpToken = token;
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
};
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);
}
// ---------------------------------------------------------------- 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()
{
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.Equal("", vm.Token);
}
[Fact]
public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken()
{
+19 -2
View File
@@ -3,20 +3,37 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace gclo.ViewModels;
/// <summary>
/// A saved connection profile: which GitHub organization to sync, where its clones
/// live on disk, and how the sync runs. Metadata only — the access token is held
/// separately in an <see cref="ITokenVault"/> keyed by <see cref="Id"/> and is never
/// serialized alongside the account.
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
/// default token when <see cref="TokenSource"/> says so) and is never serialized
/// alongside the account.
/// </summary>
public sealed record Account
{
/// <summary>Stable identity: keys the vault entry and survives renames.</summary>
public required Guid Id { get; init; }
/// <summary>
/// Where the token comes from: this account's own vault entry (the default, and
/// omitted from accounts.json so files written before this field exist stay
/// byte-identical) or the default token from Settings, resolved at use time by
/// <see cref="AccountTokenResolver"/>.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TokenSource>))]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public TokenSource TokenSource { get; init; } = TokenSource.Own;
/// <summary>True when the account resolves to the default token from Settings.</summary>
[JsonIgnore]
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
public required string Name { get; init; }
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// The one place that turns an <see cref="Account"/> into the token to use for it:
/// its own vault entry for <see cref="TokenSource.Own"/>, the default token from
/// Settings for <see cref="TokenSource.Default"/>. The desktop workspace, Sync All,
/// the account wizard and the CLI all resolve through here, so no consumer reads
/// the vault for an account directly (docs/plans/default-token-ux.md).
/// </summary>
public sealed class AccountTokenResolver
{
private readonly ITokenVault _vault;
/// <summary>Resolves against <paramref name="vault"/>.</summary>
public AccountTokenResolver(ITokenVault vault)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
}
/// <summary>
/// The token for <paramref name="account"/>, or null when its source holds
/// nothing (no default token saved, or the account's own entry is gone).
/// </summary>
public string? TryResolve(Account account)
{
ArgumentNullException.ThrowIfNull(account);
string? token = account.TokenSource == TokenSource.Default
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
: _vault.TryRetrieve(account.Id);
return string.IsNullOrEmpty(token) ? null : token;
}
/// <summary>True when the vault holds a default token.</summary>
public bool HasDefaultToken => !string.IsNullOrEmpty(_vault.TryRetrieve(AppSettings.DefaultTokenVaultId));
/// <summary>
/// Why <see cref="TryResolve"/> returned null for <paramref name="account"/>, worded
/// for the user and naming the source, so every "no token" message says what to fix.
/// Never contains a token.
/// </summary>
public static string MissingDescription(Account account)
{
ArgumentNullException.ThrowIfNull(account);
return account.TokenSource == TokenSource.Default
? "uses the default token, but no default token is saved (desktop app: Settings)"
: $"has no token in Windows Credential Manager (entry 'gclo:account:{account.Id:N}')";
}
}
+115 -11
View File
@@ -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,27 +329,52 @@ 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.");
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.");
}
@@ -333,7 +433,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 +456,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,
};
+67
View File
@@ -136,6 +136,17 @@ public sealed class AccountsStore
}
string verb = index >= 0 ? "updated" : "added";
if(account.UsesDefaultToken)
{
if(token is not null)
{
throw new ArgumentException(
"An account that uses the default token has no token of its own to store.", nameof(token));
}
SaveUsingDefaultToken(updated, account, verb);
return;
}
if(token is null)
{
Persist(updated); // IO failures propagate; _accounts stays unchanged then.
@@ -191,6 +202,62 @@ public sealed class AccountsStore
/// <summary>
/// Saves an account that resolves to the default token: its metadata is written
/// and any token of its own is removed from the vault, vault first, with the same
/// put-it-back compensation as a delete. Called under the gate.
/// </summary>
private void SaveUsingDefaultToken(List<Account> updated, Account account, string verb)
{
string? ownToken = _vault.TryRetrieve(account.Id);
if(ownToken is not null)
{
_vault.Delete(account.Id);
}
try
{
Persist(updated);
}
catch(Exception persistFailure)
{
if(ownToken is null)
{
throw;
}
try
{
_vault.Store(account.Id, ownToken);
}
catch(Exception compensationFailure)
{
string leftover = $"account {account.Id:N} ('{account.Name}') lost its own token from the vault while accounts.json "
+ "still says it has one; edit it and enter the token again, or switch it to the default token";
_log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure);
throw new AccountConsistencyException(
$"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring its token "
+ $"failed too ({compensationFailure.Message}). Current state: {leftover}.",
persistFailure, compensationFailure);
}
throw;
}
_accounts = updated;
_log.Info($"Account '{account.Name}' {verb}; it uses the default token"
+ (ownToken is null ? "." : " (its own token was removed from the vault)."));
}
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
public int CountUsingDefaultToken()
{
lock(_gate)
{
return _accounts.Count(a => a.UsesDefaultToken);
}
}
/// <summary>
/// Removes the account's vault token and then its metadata; an unknown id is a
/// no-op. A vault failure changes nothing. If the metadata write fails after the
+28 -3
View File
@@ -34,11 +34,36 @@ public static class GcloPaths
/// <summary>
/// %LOCALAPPDATA%\KofTwentyTwo\gclo: the root used when GCLO_DATA_DIR is not
/// set. Outside the Velopack install root on purpose (see the type remarks).
/// 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).
/// </summary>
public static string DefaultDataRoot =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KofTwentyTwo", "gclo");
"KofTwentyTwo", DataFolderName);
}
+20
View File
@@ -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,
}
+125
View File
@@ -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}.";
}
}
+10
View File
@@ -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)
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// Where an account's GitHub token comes from. <see cref="Own"/> is the account's
/// own vault entry (keyed by its id); <see cref="Default"/> is the default token
/// saved in Settings (<see cref="AppSettings.DefaultTokenVaultId"/>), read at use
/// time, so replacing the default token in Settings rotates every such account
/// at once (docs/plans/default-token-ux.md).
/// </summary>
public enum TokenSource
{
/// <summary>The account's own token, stored under its id. The on-disk default.</summary>
Own = 0,
/// <summary>The default token from Settings, resolved whenever the account is used.</summary>
Default = 1,
}
+30 -2
View File
@@ -140,8 +140,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
TargetFolder = account.TargetRoot;
CreateOrgSubfolder = account.CreateOrgSubfolder;
MaxConcurrency = account.MaxConcurrency;
// The Token setter's existing org lookup fires naturally with the vault token.
Token = tokenVault?.TryRetrieve(account.Id) ?? "";
// The Token setter's existing org lookup fires naturally with the resolved
// token: the account's own entry, or the default token from Settings.
Token = tokenVault is null ? "" : new AccountTokenResolver(tokenVault).TryResolve(account) ?? "";
}
_constructed = true;
}
@@ -156,6 +157,33 @@ 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;
/// <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();
+20
View File
@@ -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"
@@ -102,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"
+39 -2
View File
@@ -57,12 +57,33 @@ 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_…";
// 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;
+1
View File
@@ -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}"
+34 -12
View File
@@ -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)
+43 -5
View File
@@ -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);
+4 -1
View File
@@ -30,7 +30,10 @@
<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" Click="GitHubMenuItem_Click" />
<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 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">
+93 -10
View File
@@ -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;
@@ -116,7 +121,10 @@ public sealed partial class MainWindow : Window
InitializeComponent();
ApplyBackdrop();
Title = "gclo — Git Clone Large Organizations";
// 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";
// Quick Sync is declared in XAML; its Guid tag and the account items are
// runtime data, so they are filled in here.
@@ -137,10 +145,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 +413,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))
{
@@ -615,11 +640,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 +653,40 @@ 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;
}
// 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,
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;
}
/// <summary>The Edit/Delete context menu for an account's pane item.</summary>
private MenuFlyout CreateAccountContextFlyout(Guid id)
{
@@ -688,7 +748,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 +798,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 +861,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");
}
@@ -1027,7 +1091,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,
};
@@ -1071,9 +1135,28 @@ 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)
{
_log.Info($"Opening {RepoUrl} in the browser.");
await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl));
string url = RepoUrl + path;
_log.Info($"Opening {url} in the browser.");
await Windows.System.Launcher.LaunchUriAsync(new Uri(url));
}
+9
View File
@@ -4,6 +4,7 @@
*/
using System;
using gclo.ViewModels;
using Velopack;
@@ -25,6 +26,14 @@ 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) =>
+17 -4
View File
@@ -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
View File
@@ -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;
}
+9 -1
View File
@@ -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}" />