Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 e2e099ed08 feat(app): gclo opens one window per data folder, and a second start brings the open one to the front (#217)
windows / windows (push) Successful in 3m7s
build / windows (push) Successful in 3m7s
Two windows on one data folder each kept their own copy of the settings and
accounts, and the one that saved last undid what the other had saved. The
owner's decision (#216): one window per data folder.

- WindowClaim (gclo.ViewModels): a window holds window.lock in its data
  folder while it is open, shared with nobody. Windows gives it up when the
  process ends, however it ends. A folder that cannot be written to holds no
  claim and stops no window from opening.
- A second start finds the claim, asks the open window to come to the front
  through an event named after the folder, and ends before any window or
  file of its own exists. The open window comes out of the taskbar and
  before the other windows.
- The claim is given up first thing when the window closes, so a start right
  after closing opens a window of its own.
- Another data folder is another claim: the dev channel beside the stable
  one, and GCLO_DATA_DIR. The command line makes no claim.
- README, threat model (review log), CLAUDE.md.

Included, from the review of this pull request:
- fix(app): the claim on the data folder never stops a start, and is given up when no window came of it

Closes #216

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:55:16 +00:00
forge-recovery 9ab80f8c93 ci(gitea): build, test, coverage and format on the Windows host runner (#220)
windows / windows (push) Successful in 6m40s
build / windows (push) Successful in 6m40s
2026-11-11 22:42:24 +00:00
james.maesandClaude Fable 5.2 fa5887e6b3 docs(release): the documents name 1.2.0 as the release that moves updates to git.kof22.com (#219)
There is no release numbered 1.1.1: the fixes planned for it and Gitea
support go out together as 1.2.0 (owner's decision, #200). The README, the
CLI guide, ADR 0009, the header of the threat model and a comment in
Directory.Build.props still named 1.1.1.

docs/RELEASING.md is left to the work on the release pipeline (#197).

Closes #218

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:40:54 +00:00
james.maesandClaude Fable 5.2 d5d4f6763f feat(app): Settings holds a default token per forge, and a default token is used only for the place it was saved for (#215)
Step 5 of Gitea support (#201). Settings was all about GitHub; GitHub and
Gitea are equal options there too.

- Settings has a "Default tokens" section with two groups, named as
  everywhere else: GitHub (token) and Gitea (the server's address and its
  token). The Gitea address and token are saved, replaced and removed
  together; the saved token is never carried to another address, and a token
  for an address that cannot be used is not saved (Save is withheld, with the
  reason).
- The rule "the default token is a GitHub token" is replaced by: a default
  token belongs to one place and is never used for another. It is in the name
  of the vault entry: GitHub's is the one it always was, a Gitea server's is
  made from the server's address. Asking for the default token of a place
  finds the one saved for exactly that place, or none.
- Quick Sync fills in the default token of the place on its card.
- An account that uses the default token gets the one of its own place. The
  wizard offers the choice for a place that has one; accounts add and
  accounts edit take --use-default-token for any place that has one.
- A workspace is told which place's default token changed, and takes only its
  own.
- README, docs/CLI.md, ADR 0010, threat model T16.

Included, from the reviews of this pull request:
- fix(app): a default token is never left out of sight, and a wizard is given the default token of its own place only
- fix(app): a window that closes does not put older settings back, and a token typed behind the mask is not rewritten in its box
- fix(app): a default token that is gone when Save is pressed is not taken up, and the saved default is not given up for it

Closes #212

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:28:24 +00:00
james.maesandClaude Fable 5.2 b7d54735c8 feat(app): the next version is 1.2.0, and gclo describes itself as for GitHub and Gitea alike (#214)
- VersionPrefix is 1.2.0: a build from the line that brings Gitea support says
  1.2.0-dev, not 1.1.1-dev.
- The Share invitation and its subject, the command line's help line, the
  README's tagline and the engine package's description say GitHub or Gitea
  where they said GitHub.

Closes #211

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:28:14 +00:00
james.maesandClaude Fable 5.2 9a09cee62f feat(app): Quick Sync connects to GitHub or Gitea, sends a token only when asked, and the app opens where it was left (#210)
Step 4 of Gitea support (#201). GitHub and Gitea are equal options on the
card the app opens on, with the same two labels as the wizard.

- Quick Sync's card asks where the repositories are: GitHub or Gitea, and for
  Gitea the server's address. On a first run nothing is chosen, and the token
  box takes a token only once a place is. The title, the token box and the
  "How to create a token" link follow the place.
- Nothing is sent until the user asks. A token that is typed, pasted or filled
  in from a saved default goes nowhere by itself: the organization list is
  fetched when Find is pressed, and the repositories when Load is. Both go to
  the listers of the place on the card. An account's page lists and loads on
  open as before: its token was saved for its place.
- A server address is taken when its box is left, not while it is typed.
- Choosing another place empties the token, the organization and the table:
  the table's clone addresses are on the other host.
- The card opens the way the last connection that worked left it (place,
  address, organization, target folder, in settings.json). Never a token.
- Once a connection loaded its repositories, a bar offers to save it as an
  account; the account is saved on the place the connection was on.
- The app opens on the workspace that was open last; with nothing recorded,
  on the first account; with no account, on Quick Sync.
- The connection chip names the place, and the hint about a token that sees
  no organization is in the words of the forge.
- README, ADR 0010, threat model T16, CLAUDE.md.

Included, from the reviews of this pull request:
- fix(app): no place is chosen by keyboard focus, and the pane's button closes and opens the pane
- fix(app): Quick Sync uses no address that is being edited or cannot be used, and forgets what was typed before a place was chosen

Closes #209
Closes #213

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:25:02 +00:00
53 changed files with 4684 additions and 288 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
# Gitea CI for gclo on the Windows host runner. Mirrors .github/workflows/ci.yml
# (build-test + format); the FlaUI ui-tests job needs an interactive desktop and stays on GitHub.
# Push only: host runners must never run fork pull requests.
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore gclo.slnx --locked-mode
dotnet build gclo.slnx -p:Platform=x64 -warnaserror -p:RestoreLockedMode=true
dotnet test gclo.Engine.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
dotnet test gclo.Cli.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
$files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml
if (-not $files) { throw 'No coverage.cobertura.xml found under TestResults.' }
foreach ($name in 'gclo.Engine', 'gclo.ViewModels', 'gclo') {
$rates = foreach ($f in $files) {
[xml]$doc = Get-Content -LiteralPath $f.FullName
$pkg = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($pkg) { [double]::Parse($pkg.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates) { throw "Package '$name' not found in any coverage report." }
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output "$name line coverage: $([math]::Round($best * 100, 2))% (required 100%)"
if ($best -lt 1.0) { throw "Coverage gate FAILED: $name below 100%." }
}
dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore
+8 -1
View File
@@ -109,7 +109,11 @@ together are the "Core": all logic lives there, under the coverage gate.
(over `IProcessRunner`) is the alternative recovery.
- **`gclo.ViewModels`** is UI-framework-free (CommunityToolkit.Mvvm, partial-property
form). `WorkspaceViewModel` is one workspace (Quick Sync or a saved account): load,
filter and select, sync, retry, path recovery. Engine progress reaches it through
filter and select, sync, retry, path recovery. Quick Sync's place (GitHub, or a
Gitea server) is chosen on its card: no place is preselected, a token is sent
only when the user presses Find or Load and only to the listers of the place
on the card, and choosing another place forgets the token, the organization
and the table (ADR 0010). `StartWorkspace` picks the workspace the app opens on. Engine progress reaches it through
`BatchedRepoProgress` (a few deliveries a second, the latest report per repository;
one dispatcher callback per report froze the window on large runs). The table the
view binds is `FilteredRepos`, kept in order row by row (`PlaceInTable`,
@@ -141,6 +145,9 @@ together are the "Core": all logic lives there, under the coverage gate.
`UiTestFixture`, which answers from a fixed offline organization only when
`GCLO_UITEST_FIXTURE=1` **and** the token is the fixture's magic token. `Program.cs`
replaces the XAML-generated `Main` so `VelopackApp.Build().Run()` executes first, and
ends at once when another window holds the data folder (`WindowClaim` in
`gclo.ViewModels`: one window per data folder, the open one is asked to come to
the front), and
switches `GcloPaths.Edition` to `dev` for the `gclo-dev` app id (the dev channel
installs beside stable with its own data root). `ImplicitUsings` is off in this
project only. Handlers and `x:Bind` targets look unused to analyzers; the relaxations
+3 -2
View File
@@ -53,9 +53,10 @@
built-in SourceLink appends the git commit as "+<sha>" build metadata on
AssemblyInformationalVersion, which the About dialog and the CLI's
version output surface as the short hash. Prefix tracks the next expected
release after the last stable tag (v1.1.0). -->
release: 1.2.0, which carries the fixes that were planned as 1.1.1 and
Gitea support together (there is no release numbered 1.1.1). -->
<PropertyGroup>
<VersionPrefix>1.1.1</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
+18 -15
View File
@@ -2,7 +2,7 @@
<img src="docs/images/gclo-lockup.png" width="440" alt="gclo — Git Clone Large Organizations" />
</p>
<p align="center"><b>Clone an entire GitHub organization. Keep it up to date. In parallel.</b></p>
<p align="center"><b>Clone an entire GitHub or Gitea organization. Keep it up to date. In parallel.</b></p>
<p align="center">
<a href="https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/gclo"><img src="https://api.scorecard.dev/projects/github.com/KofTwentyTwo/gclo/badge" alt="OpenSSF Scorecard" /></a>
@@ -81,50 +81,53 @@ gclo authenticates to GitHub with a personal access token — it is used for bot
| Classic PAT | `repo` scope (read access to the org's repositories); optionally `read:org` so the organization dropdown / `gclo orgs` can list your orgs |
| Fine-grained PAT | Repository read access for the organization |
**Token storage.** In **Quick Sync**, the token is held in memory for the session only and never written anywhere. A **saved account's** token and the optional **default token** (Settings) are kept in the **Windows Credential Manager** (per-user, DPAPI-protected) — never in `settings.json`, `accounts.json`, or any log. See [SECURITY.md](SECURITY.md). A saved token is read at the moment it is used: replacing or removing the default token in Settings, or an account's token in its wizard, takes effect for the workspaces that are already open, without a restart. Quick Sync follows a changed default token only while its token box still holds the one Settings put there.
**Token storage.** In **Quick Sync**, the token is held in memory for the session only and never written anywhere. A **saved account's** token and the optional **default tokens** (File > Settings: one for GitHub, and one for a Gitea server together with its address) are kept in the **Windows Credential Manager** (per-user, DPAPI-protected) — never in `settings.json`, `accounts.json`, or any log. See [SECURITY.md](SECURITY.md). A saved token is read at the moment it is used: replacing or removing the default token in Settings, or an account's token in its wizard, takes effect for the workspaces that are already open, without a restart. Quick Sync follows a changed default token only while its token box still holds the one Settings put there.
## Using the GUI
On launch, gclo shows a brief branded splash, then the **Quick Sync** connect card:
On launch, gclo shows a brief branded splash and then opens where you left off: the workspace that was open last, or your first account, or, when there is no account yet, the **Quick Sync** connect card:
<p align="center">
<img src="docs/images/gclo-splash.png" width="380" alt="gclo startup splash" />
&nbsp;&nbsp;
<img src="docs/images/gclo-connect.png" width="440" alt="Quick Sync connect card: personal access token, organization dropdown, target folder with subfolder toggle and path preview, and Load repositories" />
<img src="docs/images/gclo-connect.png" width="440" alt="Quick Sync connect card: the choice between GitHub and Gitea with the server address, the access token, the organization box with its Find button, the target folder, and Load repositories" />
</p>
1. **Paste your token first.** After a moment, the **Organization or account** dropdown fills with everything the token can sync — your personal account is listed first, then your organizations alphabetically. If the token cannot list organizations (a fine-grained PAT, or a classic PAT without `read:org`), the status line says so; the dropdown is editable, so just type the name.
2. **Pick the organization or account** from the dropdown.
3. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `<org>` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`).
4. **Press Load repositories.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip, a third time to return to the default order); use the funnel on any column to filter by name, status, branch, or archived state.
1. **Choose where the repositories are:** **GitHub** or **Gitea**. For Gitea, enter the address of the server (HTTPS only; a Forgejo server works the same way). On a first run nothing is chosen for you, and the token box takes a token only once you chose. Later the card opens the way the last connection that worked left it: place, server address, organization and target folder. A token is never kept by Quick Sync.
2. **Enter your token.** Nothing is sent yet. Press **Find** beside **Organization or account** to list everything the token can sync: your personal account first, then your organizations alphabetically. That, and **Load repositories**, are the moments a token leaves your machine, and it goes to the place chosen on the card and nowhere else. If the token cannot list organizations (on GitHub a fine-grained PAT, or a classic PAT without `read:org`; on Gitea a token without `read:organization`), the status line says so; the box is editable, so just type the name.
3. **Pick the organization or account** from the dropdown.
4. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `<org>` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`).
5. **Press Load repositories.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip, a third time to return to the default order); use the funnel on any column to filter by name, status, branch, or archived state.
<p align="center">
<img src="docs/images/gclo-repos.png" width="820" alt="Loaded repository table: 59 repositories all selected, with sortable, filterable Name / Status / Branch / Archived columns and a Sync 59 repos button" />
</p>
5. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once.
6. **Set parallelism** if you like: the gear button (**Sync options**) in the toolbar has **Parallel clones** (1–64, default 8), how many git operations run at once.
7. **Press Sync** (the button names how many repositories are selected: **Sync 45 repos**). Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). By default the table follows the sync, so what is happening is always in view: repositories in progress are on top, then the failed ones, then the finished ones with the latest first, then the ones still waiting. When the sync is over, the failed ones come first and the rest are in name order. A column you sorted by yourself stays as you set it.
6. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once.
7. **Set parallelism** if you like: the gear button (**Sync options**) in the toolbar has **Parallel clones** (1–64, default 8), how many git operations run at once.
8. **Press Sync** (the button names how many repositories are selected: **Sync 45 repos**). Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). By default the table follows the sync, so what is happening is always in view: repositories in progress are on top, then the failed ones, then the finished ones with the latest first, then the ones still waiting. When the sync is over, the failed ones come first and the rest are in name order. A column you sorted by yourself stays as you set it.
<p align="center">
<img src="docs/images/gclo-results.png" width="820" alt="Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59" />
</p>
8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows and leaves your selection alone), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running.
9. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows and leaves your selection alone), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running.
**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. What it completes is its own. On Windows, a file that is already where the repository has one is replaced only when it is what the unfinished checkout wrote there (its content, or the beginning of it, line endings aside); for anything else the row fails and names the file: move it and sync again. The same check guards a repository that was empty when it was cloned and gets its first files later. Such a clone follows the origin's default branch when nothing has been put into its folder (otherwise it keeps the branch its HEAD names, and stays empty until origin has that branch); a clone that has branches of its own is left as it is when its HEAD names a branch without commits (a branch you started there yourself). If you have worked in an unfinished clone with git in the meantime, gclo finishes it only when every file there is still the repository's own and nothing you added with `git add` would be removed; otherwise the row fails and names the file. While a canceled clone or update is still ending, syncing that repository again fails with a message to try again when it has finished. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo/<org>/<repo>` inside WSL (reachable from Explorer as `\wsl.localhost\<distro>\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`).
**Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**Settings** (File > Settings…) let you set default tokens (one for GitHub, and one for a Gitea server together with its address; stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**One window:** gclo opens one window per user. Starting it again brings the open window to the front instead of opening a second one (two windows would each keep their own copy of the settings and undo each other's changes). The command line is not affected and can run beside the app.
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters, per-repo failures, and when the window stopped responding (for how long, and what was running), and never contain your token (see [SECURITY.md](SECURITY.md)).
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.1.1 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.2.0 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
### Saved accounts
Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Quick Sync connects to GitHub. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
Quick Sync is ad-hoc — no token is saved. Once a connection has loaded its repositories, a bar above the table offers **Save as account…**. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
<p align="center">
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name, optional description, and where the repositories are, with a Gitea server chosen and its address entered" />
+7 -6
View File
@@ -407,10 +407,11 @@ else: to move an account, remove it and add it again.
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.
The default token is a GitHub token: `--use-default-token` is for GitHub
accounts only, and an account on a Gitea server always has a token of its own.
such account at once. Settings holds one default token for GitHub and one for a
Gitea server; an account gets the one of the place it is on, and a default
token is never used for another place. The command fails (exit 2) when no
default token is saved for the account's place, because the account could never
sync; the CLI cannot set a default token.
```powershell
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
@@ -536,7 +537,7 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
`-eq 2` for "usage error" should treat `3` and `4` as distinct outcomes.
Before version 1.1.1, a refusal of access other than an invisible organization
Before version 1.2, a refusal of access other than an invisible organization
(403), a rate limit answered with 429, a network failure, a timeout, and an
error on GitHub's side ended with code 70; they now end with `3` (the refusal)
or `4` (the rest).
@@ -652,7 +653,7 @@ done
```console
$ gclo --version
1.1.1 (a893741d2)
1.2.0 (a893741d2)
```
Released builds print the semantic version and the short commit hash; local builds print the next-release version with a `-dev` suffix (e.g. `1.1.2-dev (<hash>)`).
@@ -29,7 +29,8 @@ would install an old version for ever.
- Versions up to 1.1.0 look for updates on GitHub. They find a later version only
if it is also published there; otherwise they are updated once by hand. From
1.1.1 on the app looks on `git.kof22.com`.
1.2.0 on the app looks on `git.kof22.com` (the change was made for a 1.1.1
that was not released on its own; it ships in 1.2.0).
- The availability and the integrity of updates now rest on a self-hosted server
instead of GitHub's release hosting. The updater verifies a package against the
hash in the feed, and the feed comes from the same server; it checks no signature
+72 -7
View File
@@ -113,10 +113,11 @@ were the two lister implementations and the failure type they throw.
hold for a file changed by hand: at another address the account has no token.
The price: a token whose account was edited that way stays in Credential Manager
under a name gclo no longer derives, until the address is put back.
- **Tokens do not cross forges by default.** The default token saved in Settings
is a GitHub token: an account on another forge has a token of its own, and is
never handed the default one, whatever its record says. On the command line
each forge has its own default variable (`GITHUB_TOKEN`, `GITEA_TOKEN`).
- **Tokens do not cross places by default.** A default token saved in Settings
belongs to one place (GitHub, or one Gitea server) and is never handed to an
account on another, whatever its record says: see "A default token per place"
below. On the command line each forge has its own default variable
(`GITHUB_TOKEN`, `GITEA_TOKEN`).
- **One place pairs an account with its listers** (`AccountListers`, over
`ForgeEndpoint`), used by the app and the command line. An account whose stored
forge or server cannot be used (a file edited by hand) gets listers that ask no
@@ -130,18 +131,82 @@ were the two lister implementations and the failure type they throw.
was normalized, in full, where the token is asked for: that is the address the
token goes to. An edit shows where the
account is and that it stays there. A Quick Sync connection saved as an account
is a GitHub account: Quick Sync itself connects to GitHub only.
is an account on the place the connection was on (#209).
- **The wizard has the lister of one place at a time.** The shell gives it a
lister per place; step 1 takes the lister of the place that was chosen, and step
2 has no other to send the token to. A wizard that was given one lister (an
edit, a saved Quick Sync connection) offers no choice.
- **What was entered for one place is not carried to another.** When step 1 is
left with another place than before, the typed token, the organization and the
list of organizations are emptied, and the default token (a GitHub token) is
neither offered nor preselected for a Gitea server.
list of organizations are emptied, and the default token on offer is the one
saved for the new place, if there is one (#212).
- **Texts follow the place.** The page a token is made on is the one of the
chosen place (`ForgeTexts`); step 2 names the place the token is checked with.
### Quick Sync and the start screen (#209)
GitHub and Gitea are equal options wherever the app asks for a place, with the
same two labels. Quick Sync, the card the app first opens on, is where that
matters most.
- **The place is chosen on the card, and nothing is chosen for the user.** On a
first run neither GitHub nor Gitea is selected, and the token box takes a token
only once one is. A token cannot be pasted for a place nobody chose.
- **Nothing is sent until the user asks.** A token that is typed, pasted, or
filled in from a saved default goes nowhere by itself. It leaves the machine
when the user presses Find (the organization list) or Load repositories, and
it goes to the listers of the place that is on the card at that moment. An
account's page still lists and loads when it is opened: its token was saved for
its place.
- **A server address is taken when its box is left**, not while it is typed, so no
token is sent to half an address; the card then shows it as it was normalized.
While the box holds another text than the address that was taken, and while
the card names no usable address, nothing can be looked up, loaded or synced,
whatever starts it (a button, a keyboard shortcut).
- **What belongs to one place is forgotten when another is chosen:** the token,
the organization, and the table. The repositories of the table have clone
addresses on the other host; they are not left there to be synced with a token
of the new place.
- **The card opens the way the last connection that worked left it:** place,
server address, organization, target folder, kept in `settings.json`. Never a
token. An older gclo that rewrites `settings.json` drops these fields; the card
then opens as on a first run, which costs a choice and nothing else.
- **The app opens where it was left:** the workspace that was open last; with
nothing recorded, the first account; with no account, Quick Sync.
### A default token per place (#212)
Settings held one default token, GitHub's, and the rule was "the default token is
a GitHub token". With GitHub and Gitea as equal options it holds one for GitHub
and one for a Gitea server, and the rule is: **a default token belongs to one
place and is never used for another.**
- **The rule is in the name of the vault entry**, not in a check somebody has to
remember: GitHub's entry is the one it always was; a Gitea server's is made
from the server's address (`DefaultTokens.EntryFor`). Asking for the default
token of a place finds the one saved for exactly that place, or none.
- **Who asks:** Quick Sync, for the place on its card; an account that uses the
default token, for its own place (`AccountTokenResolver`); the wizard, for the
place of the account; `--use-default-token` on the command line, likewise. A
place that has none offers no such choice.
- **The Gitea default is an address and a token, saved together.** Settings never
carries the saved token to another address: a changed address asks for the
token of the new server and removes the old one's; a refused address disables
Save. One default Gitea server: more servers are what accounts are for.
- The address is in `settings.json` (`DefaultGiteaServer`); the token is in the
vault. The two can come apart (an older gclo rewrites `settings.json` without
the address; the file cannot be written or read): the token is then still
found by whatever asks for the default of that server, while Settings shows
none. An address entered in Settings that already has a default token in the
vault is therefore taken up as the default it is, so the token can be seen,
replaced and removed again. With another server saved as the default, taking
one up removes that server's token, as typing a token for another server
does: there is one default Gitea server.
- What the app remembers by itself (the workspace that was open, Quick Sync's
connection) is written over `settings.json` as it is on disk at that moment,
those values only. A second gclo window that closes does not put its older
copy of the settings back, which would drop an address saved in the meantime.
## Alternatives considered
- **A Gitea client package.** One more dependency to track and to trust with the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+6 -3
View File
@@ -4,7 +4,7 @@
review it in every MINOR/MAJOR release checklist (K22-SEC-30).
Method: data-flow diagram + STRIDE per trust boundary. -->
- **Version reviewed:** v1.1.1 · **Last reviewed:** 2026-11-10
- **Version reviewed:** v1.2.0 (in work; the rows dated 2026-11-10 were written for a 1.1.1 that ships as part of 1.2.0) · **Last reviewed:** 2026-11-11
## 1. What the product is and does
@@ -100,8 +100,8 @@ flowchart LR
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token is not offered for, or sent to, a Gitea server. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another | Accepted (same-user boundary: whoever can write these files can read the tokens) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; a default token belongs to one place (GitHub, or one Gitea server: its vault entry is named after the place) and is never handed to an account, a wizard or a Quick Sync on another (#212), and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token on offer is the one saved for the place that is chosen, never another's. Quick Sync (#209): no place is chosen for the user, and its token box takes a token only once one is chosen; a token that is typed, pasted or filled in is sent nowhere until the user presses Find or Load repositories, and then to the listers of the place on the card; a server address is taken when its box is left, not while it is typed; choosing another place empties the token, the organization and the table (whose clone addresses are on the other host); what is remembered of a connection is its place, organization and folder, never a token. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token, an account's or a server's default one (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another. `settings.json` also names the place Quick Sync opens on: one that is changed there opens the card on another server with the organization filled in, and no token (Quick Sync keeps none, and a default token is found only for the place it was saved for); the title of the card names the server before anything is sent | Accepted (same-user boundary: whoever can write these files can read the tokens) |
## 7. Accepted risks
@@ -135,3 +135,6 @@ flowchart LR
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); accounts on another forge are in `accounts-forges.json`, which an older gclo does not rewrite; their token is bound to the account and its server address. T13 restated for it. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for the desktop app's Add account wizard (#206): the place is chosen before the token is typed, the token is checked with that place only, and nothing typed for one place is carried to another. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for Quick Sync (#209): GitHub and Gitea are equal on the card the app opens on; nothing is preselected and nothing is sent without the user asking; `settings.json` now also holds the place Quick Sync last connected to (a server address, no token). |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 restated for a default token per place (#212): Settings holds one for GitHub and one for a Gitea server, each under a vault entry named after its place; `settings.json` holds the address of that server. The rule "the default token is a GitHub token" is replaced by "a default token is never used for another place than its own". |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | One window per data folder (#216): the app holds `window.lock` in its data folder while its window is open, and a second start asks the open window to come to the front through a named event and ends. No new trust boundary: the file and the event are the user's own. A process running as the same user can hold the file (gclo then opens no window) or raise the event (the window comes to the front); such a process can already read and change everything gclo keeps (T6, T13). Nothing secret is in either. |
+52 -9
View File
@@ -304,17 +304,31 @@ public sealed class ForgeCliTests : IDisposable
[Fact]
public void AccountsAdd_OnTheDefaultToken_IsForGitHubOnly()
public void AccountsAdd_OnTheDefaultToken_NeedsTheDefaultTokenOfItsOwnPlace()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: it is not the server's
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, Server, out ForgeEndpoint? place, out _));
using var console = new ConsoleCapture();
CliUsageException error = Assert.Throws<CliUsageException>(
CliErrorException error = Assert.Throws<CliErrorException>(
() => Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
Assert.Equal(
"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on git.example.test a token of its own.",
"No default token is saved for https://git.example.test/, so the account could not sync. Save one in the desktop app "
+ "(File > Settings), or give the account a token of its own with --token-env, --token-file, or --token-stdin.",
error.Message);
Assert.Empty(Store.GetAll());
// With one saved for that server, the account uses it and has no token of its own.
_vault.Store(DefaultTokens.EntryFor(place), "gitea_default");
Assert.Equal(0, Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
Account added = Assert.Single(Store.GetAll());
Assert.Equal(TokenSource.Default, added.TokenSource);
Assert.Equal(ForgeKind.Gitea, added.Forge);
Assert.Null(_vault.TryRetrieve(added.TokenEntryId));
Assert.Equal("gitea_default", new AccountTokenResolver(_vault).TryResolve(added));
}
@@ -354,18 +368,18 @@ public sealed class ForgeCliTests : IDisposable
[Fact]
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_AndATokenOptionReplacesIt()
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_UnlessItsServerHasADefaultToken()
{
Account account = AddGiteaAccount();
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: it is not the server's
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, Server, out ForgeEndpoint? place, out _));
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", "replaced");
using var console = new ConsoleCapture();
try
{
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
Assert.Equal(
"Account 'forge' is on git.example.test: the default token is a GitHub token, so it keeps a token of its own.",
error.Message);
Assert.StartsWith("No default token is saved for https://git.example.test/, so the account could not sync.", error.Message, StringComparison.Ordinal);
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
Assert.Equal(0, Accounts("edit", "--name", "forge", "--token-env", "GCLO_FORGE_TEST_TOKEN"));
}
@@ -377,6 +391,35 @@ public sealed class ForgeCliTests : IDisposable
Assert.Equal("replaced", _vault.TryRetrieve(account.TokenEntryId));
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
// With a default token saved for its server, the account can be switched to it: its own token is removed.
_vault.Store(DefaultTokens.EntryFor(place), "gitea_default");
Assert.Equal(0, Accounts("edit", "--name", "forge", "--use-default-token"));
Account switched = Store.FindByName("forge")!;
Assert.Equal(TokenSource.Default, switched.TokenSource);
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
Assert.Equal("gitea_default", new AccountTokenResolver(_vault).TryResolve(switched));
}
[Fact]
public void AccountsEdit_OfAnAccountThatCannotBeUsed_DoesNotSwitchItToADefaultToken()
{
AddGiteaAccount();
string file = Path.Combine(_dir, "accounts-forges.json");
File.WriteAllText(file, File.ReadAllText(file).Replace(Server, "http://git.example.test/", StringComparison.Ordinal));
_store = null; // read the file again
using var console = new ConsoleCapture();
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
Assert.Equal(
"Account 'forge' cannot be used as it is saved: the server address cannot be used: "
+ "it has to begin with https://. Remove the account and add it again.",
error.Message);
}
+23 -22
View File
@@ -70,8 +70,9 @@ internal static class AccountsCommand
the default token saved in the desktop app's
Settings, read whenever it syncs, so replacing
the default token there updates this account too.
GitHub accounts only: the default token is a
GitHub token.
It is the default token of the place the account
is on (GitHub, or its Gitea server): one has to
be saved for that place.
edit
--name <name> The account to change (required).
@@ -84,9 +85,9 @@ internal static class AccountsCommand
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.
GitHub accounts only.
--use-default-token Switch the account to the default token of the
place it is on; its own token is removed from
Windows Credential Manager.
The forge and the server of an account cannot be changed: its token was
saved for that place. Remove the account and add it again.
@@ -276,11 +277,6 @@ internal static class AccountsCommand
string targetRoot = AbsoluteTarget(target);
ForgeEndpoint endpoint = forgeOptions.Resolve();
if(tokenOptions.UseDefault && endpoint.Kind != ForgeKind.GitHub)
{
throw new CliUsageException(
$"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on {endpoint} a token of its own.");
}
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', forge={endpoint}, token={(tokenOptions.UseDefault ? "default" : "own")}");
try
@@ -289,7 +285,7 @@ internal static class AccountsCommand
// opens, so a bad token option fails without touching accounts.json.
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve(endpoint.Kind);
(AccountsStore store, ITokenVault vault) = open(log);
RequireDefaultTokenIfUsed(tokenOptions, vault);
RequireDefaultTokenIfUsed(tokenOptions, vault, endpoint);
var account = new Account
{
Id = Guid.NewGuid(),
@@ -406,12 +402,16 @@ internal static class AccountsCommand
(AccountsStore store, ITokenVault vault) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
RefuseASharedName(store, existing.Name);
if(tokenOptions.UseDefault && existing.Forge != ForgeKind.GitHub)
tokenOptions.EnsureAtMostOneSource();
if(tokenOptions.UseDefault)
{
throw new CliErrorException(
$"Account '{existing.Name}' is on {Where(existing)}: the default token is a GitHub token, so it keeps a token of its own.");
// The default token of the place the account is on; an account whose place cannot be used has none.
ForgeEndpoint place = existing.TryGetEndpoint(out ForgeEndpoint? where, out string? unusable)
? where
: throw new CliErrorException(
$"Account '{existing.Name}' cannot be used as it is saved: {unusable}. {existing.AdviceWhenUnusable("Remove")}");
RequireDefaultTokenIfUsed(tokenOptions, vault, place);
}
RequireDefaultTokenIfUsed(tokenOptions, vault);
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve(existing.Forge) : null;
// A token option decides the source: --use-default-token switches to the
@@ -535,18 +535,19 @@ internal static class AccountsCommand
/// <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).
/// --use-default-token is only useful when a default token exists for the place
/// the account is on: 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). A default token saved for another place does not count.
/// </summary>
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault)
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault, ForgeEndpoint place)
{
tokenOptions.EnsureAtMostOneSource();
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken)
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultTokenFor(place))
{
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, "
$"No default token is saved for {place.Server?.AbsoluteUri ?? place.DisplayName}, so the account could not sync. Save one in the desktop app "
+ "(File > Settings), or give the account a token of its own with --token-env, "
+ "--token-file, or --token-stdin.");
}
}
+3 -2
View File
@@ -21,7 +21,8 @@ namespace gclo.Cli;
internal static class Program
{
private const string RootHelp = """
gclo (Git Clone Large Organizations) - clone or update every repository of a GitHub organization or user account.
gclo (Git Clone Large Organizations) - clone or update every repository of an organization or user account
on GitHub or on a Gitea server.
Usage:
gclo sync --org <name> --target <folder> [options]
@@ -60,7 +61,7 @@ internal static class Program
Exit codes:
0 success, 1 completed with failures or canceled, 2 usage or fatal error,
3 token rejected or access denied, 4 rate limit, network or GitHub-side
3 token rejected or access denied, 4 rate limit, network or server-side
failure (retry later),
70 unexpected error (details in the activity log).
""";
+6 -4
View File
@@ -735,14 +735,14 @@ public sealed class AccountFilesTests : IDisposable
// ---------------------------------------------------------------- the wizard
[Fact]
public async Task TheWizard_NeverSendsTheDefaultTokenToAnAccountOnAnotherForge_WhateverSetTheChoice()
public async Task TheWizard_SendsNoDefaultTokenToAnAccountWhoseServerHasNone_WhateverSetTheChoice()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: the wizard of this account is not given it
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(forge, "gitea_token");
var lister = new FakeOrganizationLister();
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => "ghp_default");
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => null);
wizard.TokenChoiceIndex = 0; // the choice is not shown for this account; this sets it all the same
Assert.True(await wizard.TryAdvanceAsync());
@@ -750,7 +750,9 @@ public sealed class AccountFilesTests : IDisposable
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
wizard.TokenError);
Assert.Equal(0, lister.Calls);
}
}
+33 -15
View File
@@ -144,14 +144,17 @@ public sealed class AccountForgeTests : IDisposable
// ---------------------------------------------------------------- the default token is GitHub's
[Fact]
public void TheDefaultToken_IsNeverGivenToAnAccountOnAnotherForge()
public void TheDefaultTokenOfGitHub_IsNeverGivenToAnAccountOnAGiteaServer()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
var resolver = new AccountTokenResolver(_vault);
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default };
// A default token belongs to one place: the server has none, and GitHub's is not it.
Assert.Null(resolver.TryResolve(gitea));
Assert.Contains("the default token, which is a GitHub token", resolver.MissingDescription(gitea), StringComparison.Ordinal);
Assert.Equal(
"uses the default token for git.example.test, but none is saved for it (desktop app: Settings)",
resolver.MissingDescription(gitea));
Assert.Equal("ghp_default", resolver.TryResolve(GitHubAccount() with { TokenSource = TokenSource.Default }));
}
@@ -171,14 +174,17 @@ public sealed class AccountForgeTests : IDisposable
[Fact]
public void Save_AGiteaAccountOnTheDefaultToken_IsRefused()
public void Save_AGiteaAccountOnTheDefaultTokenOfItsServer_HasNoTokenOfItsOwn()
{
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, token: null));
NewStore().Save(account, token: null);
Assert.Contains("is on git.example.test; the default token is a GitHub token", ex.Message, StringComparison.Ordinal);
Assert.Empty(NewStore().GetAll());
Account saved = Assert.Single(NewStore().GetAll());
Assert.Equal(TokenSource.Default, saved.TokenSource);
Assert.Equal(ForgeKind.Gitea, saved.Forge);
Assert.Null(_vault.TryRetrieve(saved.TokenEntryId));
Assert.Throws<ArgumentException>(() => NewStore().Save(account, "a token it has no place for")); // as for any account on a default token
}
@@ -328,10 +334,11 @@ public sealed class AccountForgeTests : IDisposable
Account account = GiteaAccount();
AccountsStore store = NewStore();
store.Save(account, "gitea_token");
// The wizard of an account is given the default token of the account's place: this server has none.
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => "ghp_default");
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => null);
// The default token is a GitHub token: it is not on offer here.
// GitHub's default token is not on offer for an account on a Gitea server.
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
@@ -352,15 +359,26 @@ public sealed class AccountForgeTests : IDisposable
[Fact]
public void TheWizard_ForAnAccountOnAnotherForgeThatSaysDefaultToken_DoesNotPreselectIt()
public async Task TheWizard_ForAGiteaAccountOnTheDefaultToken_WhoseServerHasNone_SendsNothing_AndOffersATokenOfItsOwn()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Account edited = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: not this account's
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
var lister = new FakeOrganizationLister();
var wizard = new AccountWizardViewModel(
NewStore(), new FakeOrganizationLister(), new AppSettings(), edited, () => null, defaultToken: () => "ghp_default");
NewStore(), lister, new AppSettings(), account, () => null, defaultToken: () => null);
Assert.False(wizard.UseDefaultToken);
Assert.False(wizard.OffersTokenChoice);
// It says it uses the default token, and there is none for its server: the choice is shown, so it can be given a token of its own.
Assert.True(wizard.UseDefaultToken);
Assert.True(wizard.OffersTokenChoice);
Assert.False(wizard.HasDefaultToken);
Assert.True(await wizard.TryAdvanceAsync());
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
wizard.TokenError);
Assert.Equal(0, lister.Calls);
}
}
@@ -114,11 +114,11 @@ public sealed class AccountTokenResolverTests
public void HasDefaultToken_ReflectsTheVault()
{
var resolver = new AccountTokenResolver(_vault);
Assert.False(resolver.HasDefaultToken);
Assert.False(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Assert.True(resolver.HasDefaultToken);
Assert.True(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
}
+5 -3
View File
@@ -63,7 +63,7 @@ public sealed class AccountWizardPlaceTests : IDisposable
private AccountWizardViewModel ChoosingWizard(string? defaultToken = null)
=> new(_store, ListerOf, _defaults, defaultToken: () => defaultToken);
=> new(_store, ListerOf, _defaults, defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? defaultToken : null);
@@ -538,10 +538,12 @@ public sealed class AccountWizardPlaceTests : IDisposable
Assert.Equal("Enter a token to continue.", wizard.TokenError);
Assert.Empty(_sent);
// The choice is not shown here. Set all the same, it is refused: nothing is sent.
// The choice is not shown here: the server has no default token. Set all the same, there is nothing to send.
wizard.TokenChoiceIndex = 0;
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
wizard.TokenError);
Assert.Empty(_sent);
// Back on GitHub, it is on offer again, and preselected as for any new account.
@@ -488,7 +488,7 @@ public sealed class AccountWizardViewModelTests : IDisposable
Account saved = Assert.Single(_store.GetAll());
Assert.True(saved.UsesDefaultToken);
Assert.Null(_vault.TryRetrieve(saved.Id));
Assert.Equal(1, _store.CountUsingDefaultToken());
Assert.Single(_store.UsingDefaultTokenOf(ForgeEndpoint.GitHub));
}
+3 -3
View File
@@ -138,13 +138,13 @@ public sealed class AccountsStoreTests : IDisposable
public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts()
{
AccountsStore store = NewStore();
Assert.Equal(0, store.CountUsingDefaultToken());
Assert.Empty(store.UsingDefaultTokenOf(ForgeEndpoint.GitHub));
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());
Assert.Equal(2, store.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Count);
Assert.Equal(2, NewStore().UsingDefaultTokenOf(ForgeEndpoint.GitHub).Count);
}
// ---------------------------------------------------------------- persistence
@@ -0,0 +1,760 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// A default token per forge (#212): Settings holds one for GitHub and one for a
/// Gitea server. The rule these tests are built around replaces "the default token
/// is a GitHub token": a default token belongs to one place and is never used for
/// another. Quick Sync fills in the one of the place on its card, an account that
/// uses the default token gets the one of its own place, and the Gitea one is
/// saved, replaced and removed together with the address of its server.
/// </summary>
public sealed class DefaultTokenPerPlaceTests : IDisposable
{
private const string Server = "https://git.example.test/";
private const string OtherServer = "https://other.example.test/";
private static readonly ForgeEndpoint s_gitea = Place(Server);
private static readonly ForgeEndpoint s_other = Place(OtherServer);
private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private static ForgeEndpoint Place(string server)
{
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out _));
return place;
}
private AccountsStore NewStore() => new(_vault, _root);
private static Account AccountOn(ForgeEndpoint place, string name, TokenSource source = TokenSource.Default) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
Forge = place.Kind,
Server = place.Server?.AbsoluteUri,
TokenSource = source,
};
// ---------------------------------------------------------------- where a default token is kept
[Fact]
public void TheEntryOfADefaultToken_IsGitHubsAsEver_AndForAGiteaServerMadeOfItsAddress()
{
Assert.Equal(AppSettings.DefaultTokenVaultId, DefaultTokens.EntryFor(ForgeEndpoint.GitHub));
// Pinned: tokens that are saved are found again by this value.
Assert.Equal(new Guid("18734666-9532-7707-54df-1f8d61342bc1"), DefaultTokens.EntryFor(s_gitea));
Assert.NotEqual(DefaultTokens.EntryFor(s_gitea), DefaultTokens.EntryFor(s_other));
// Two servers on one host are two places: each has an entry of its own.
ForgeEndpoint underAPath = Place("https://git.example.test/gitea/");
Assert.NotEqual(DefaultTokens.EntryFor(s_gitea), DefaultTokens.EntryFor(underAPath));
Assert.NotEqual(DefaultTokens.EntryFor(underAPath), DefaultTokens.EntryFor(Place("https://git.example.test/mirror/")));
Assert.Throws<ArgumentNullException>(() => DefaultTokens.EntryFor(null!));
}
[Fact]
public void ADefaultToken_IsFoundForThePlaceItWasSavedFor_AndForNoOther()
{
Assert.Null(DefaultTokens.For(_vault, ForgeEndpoint.GitHub));
Assert.Null(DefaultTokens.For(_vault, s_gitea));
_vault.Store(DefaultTokens.EntryFor(ForgeEndpoint.GitHub), "ghp_default");
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
Assert.Equal("ghp_default", DefaultTokens.For(_vault, ForgeEndpoint.GitHub));
Assert.Equal("gitea_default", DefaultTokens.For(_vault, s_gitea));
Assert.Null(DefaultTokens.For(_vault, s_other));
Assert.Throws<ArgumentNullException>(() => DefaultTokens.For(null!, ForgeEndpoint.GitHub));
_vault.Store(DefaultTokens.EntryFor(s_other), "");
Assert.Null(DefaultTokens.For(_vault, s_other)); // an empty entry is no token
}
// ---------------------------------------------------------------- an account on the default token
[Fact]
public void AnAccountOnTheDefaultToken_GetsTheOneOfItsOwnPlace_AndNeverAnothers()
{
_vault.Store(DefaultTokens.EntryFor(ForgeEndpoint.GitHub), "ghp_default");
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
var resolver = new AccountTokenResolver(_vault);
Assert.Equal("ghp_default", resolver.TryResolve(AccountOn(ForgeEndpoint.GitHub, "Work")));
Assert.Equal("gitea_default", resolver.TryResolve(AccountOn(s_gitea, "Forge")));
// A server that has no default token gets none: neither GitHub's nor the other server's.
Account elsewhere = AccountOn(s_other, "Elsewhere");
Assert.Null(resolver.TryResolve(elsewhere));
Assert.Equal(
"uses the default token for other.example.test, but none is saved for it (desktop app: Settings)",
resolver.MissingDescription(elsewhere));
// An account whose place cannot be used has no place to have a default token of.
Assert.Null(resolver.TryResolve(AccountOn(s_gitea, "Broken") with { Server = "http://git.example.test" }));
Assert.Null(resolver.TryResolve(AccountOn(s_gitea, "Later") with { ForgeName = "GitLab" }));
Assert.True(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
Assert.True(resolver.HasDefaultTokenFor(s_gitea));
Assert.False(resolver.HasDefaultTokenFor(s_other));
_vault.Delete(DefaultTokens.EntryFor(ForgeEndpoint.GitHub));
Assert.Equal(
"uses the default token, but no default token is saved (desktop app: Settings)",
resolver.MissingDescription(AccountOn(ForgeEndpoint.GitHub, "Work")));
}
[Fact]
public void AnAccountOnAGiteaServer_CanBeSavedAsUsingTheDefaultToken_AndIsCountedForItsPlaceOnly()
{
AccountsStore store = NewStore();
Account work = AccountOn(ForgeEndpoint.GitHub, "Work");
Account forge = AccountOn(s_gitea, "Forge");
Account elsewhere = AccountOn(s_other, "Elsewhere");
Account own = AccountOn(s_gitea, "Own", TokenSource.Own);
store.Save(work, token: null);
store.Save(forge, token: null);
store.Save(elsewhere, token: null);
store.Save(own, "gitea_own");
Assert.Equal(TokenSource.Default, NewStore().FindByName("Forge")!.TokenSource);
Assert.Equal(["Work"], store.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Select(a => a.Name), StringComparer.Ordinal);
Assert.Equal(["Forge"], store.UsingDefaultTokenOf(s_gitea).Select(a => a.Name), StringComparer.Ordinal);
Assert.Equal(["Elsewhere"], store.UsingDefaultTokenOf(s_other).Select(a => a.Name), StringComparer.Ordinal);
Assert.Throws<ArgumentNullException>(() => store.UsingDefaultTokenOf(null!));
}
// ---------------------------------------------------------------- Settings: the Gitea group
[Fact]
public void WithNothingSaved_TheGiteaGroup_SavesAnAddressAndItsTokenTogether()
{
var state = new SettingsGiteaState(savedPlace: null, hasSavedToken: false, dependentAccounts: []);
Assert.False(state.HasSavedToken);
Assert.Equal("", state.InitialServer);
Assert.Equal("", state.InitialBoxValue);
Assert.False(state.CanOfferRemoval(""));
// Nothing entered: nothing to do.
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, null, ""), state.Plan("", ""));
Assert.Equal("No default token saved for a Gitea server.", state.Caption("", ""));
Assert.False(state.IsWarning("", ""));
// An address alone is no default.
Assert.Equal(SettingsTokenAction.Keep, state.Plan("git.example.test", "").Action);
Assert.False(state.Plan("git.example.test", "").IsError);
// A token without an address has no server to be for.
SettingsGiteaPlan noAddress = state.Plan(" ", "gitea_typed");
Assert.True(noAddress.IsError);
Assert.Equal("Enter the address of the server this token is for.", noAddress.Error);
Assert.Equal(noAddress.Error, state.Caption(" ", "gitea_typed"));
Assert.True(state.IsWarning(" ", "gitea_typed"));
// An address that cannot be used is said so, and not repeated.
SettingsGiteaPlan refused = state.Plan("https://bot:hunter2@git.example.test", "gitea_typed");
Assert.True(refused.IsError);
Assert.Equal("The server address cannot be used: it contains a user name or a password; the token is entered separately.", refused.Error);
// Both: stored for that server, as its address is normalized.
SettingsGiteaPlan store = state.Plan("git.example.test", "gitea_typed");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), store);
Assert.Equal(
"The default token for https://git.example.test/ will be saved on Save; Quick Sync fills it in when it is on that server.",
state.Caption("git.example.test", "gitea_typed"));
var settings = new AppSettings();
Assert.Equal("default token for git.example.test stored", state.Apply(store, "gitea_typed", _vault, settings));
Assert.Equal(Server, settings.DefaultGiteaServer);
Assert.Equal(s_gitea, settings.DefaultGiteaPlace());
Assert.Equal("gitea_typed", DefaultTokens.For(_vault, s_gitea));
Assert.Null(DefaultTokens.For(_vault, ForgeEndpoint.GitHub)); // GitHub's is another entry
}
[Fact]
public void WithATokenSaved_TheGiteaGroup_KeepsReplacesAndRemovesIt_ForItsServer()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge", "Mirror"]);
Assert.True(state.HasSavedToken);
Assert.Equal(Server, state.InitialServer);
Assert.Equal(SettingsTokenState.Mask, state.InitialBoxValue); // never the token
Assert.True(state.CanOfferRemoval(SettingsTokenState.Mask));
Assert.False(state.CanOfferRemoval("typed"));
// Untouched: kept, and it says who uses it.
Assert.Equal(SettingsTokenAction.Keep, state.Plan(Server, SettingsTokenState.Mask).Action);
Assert.Equal(
"A default token is saved for https://git.example.test/ and used by Quick Sync and 2 accounts. Type to replace it; clear the box to remove it.",
state.Caption(Server, SettingsTokenState.Mask));
Assert.False(state.IsWarning(Server, SettingsTokenState.Mask));
Assert.Null(state.Apply(state.Plan(Server, SettingsTokenState.Mask), SettingsTokenState.Mask, _vault, settings));
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
// The same server spelled another way is the same server.
Assert.Equal(SettingsTokenAction.Keep, state.Plan("HTTPS://GIT.example.test", SettingsTokenState.Mask).Action);
// Typed over: replaced, for the same server.
SettingsGiteaPlan replace = state.Plan(Server, "gitea_new");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), replace);
Assert.Equal(
"The default token will be replaced on Save. The 2 accounts that use it switch to the new token too.",
state.Caption(Server, "gitea_new"));
Assert.False(state.IsWarning(Server, "gitea_new"));
Assert.Equal("default token for git.example.test replaced", state.Apply(replace, "gitea_new", _vault, settings));
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
// A token typed with the address taken out has no server to be for: nothing is stored, nothing removed.
SettingsGiteaPlan noAddress = state.Plan("", "gitea_for_nowhere");
Assert.True(noAddress.IsError);
Assert.Equal("Enter the address of the server this token is for.", noAddress.Error);
Assert.Throws<InvalidOperationException>(() => state.Apply(noAddress, "gitea_for_nowhere", _vault, settings));
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
// The box cleared, or the address cleared: removed, with the accounts it breaks named.
foreach((string server, string box) in ((string, string)[])[(Server, ""), ("", SettingsTokenState.Mask), ("", "")])
{
SettingsGiteaPlan remove = state.Plan(server, box);
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Remove, s_gitea, ""), remove);
Assert.Equal(
"The default token will be removed on Save. 2 accounts use it and will stop syncing until a new one is saved: Forge, Mirror.",
state.Caption(server, box));
Assert.True(state.IsWarning(server, box));
}
Assert.Equal(
"default token for git.example.test removed (2 account(s) depend on it)",
state.Apply(state.Plan(Server, ""), "", _vault, settings));
Assert.Null(DefaultTokens.For(_vault, s_gitea));
Assert.Null(settings.DefaultGiteaServer);
Assert.Null(settings.DefaultGiteaPlace());
}
[Fact]
public void TheSavedGiteaToken_IsNeverCarriedToAnotherAddress()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge"]);
// Another address with the saved token still in the box: refused, and Save is not offered.
SettingsGiteaPlan refused = state.Plan(OtherServer, SettingsTokenState.Mask);
Assert.True(refused.IsError);
Assert.Equal(
"The saved token is for https://git.example.test/. Enter the token for https://other.example.test/, or put the address back.",
refused.Error);
Assert.True(state.IsWarning(OtherServer, SettingsTokenState.Mask));
Assert.Throws<InvalidOperationException>(() => state.Apply(refused, SettingsTokenState.Mask, _vault, settings));
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
Assert.Null(DefaultTokens.For(_vault, s_other));
// With the token of the new server typed: stored for it, and the old server's is removed.
SettingsGiteaPlan move = state.Plan(OtherServer, "other_typed");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_other, ""), move);
Assert.Equal(
"The default token for https://other.example.test/ will be saved on Save, and the one for https://git.example.test/ removed."
+ " 1 account uses it and will stop syncing until a new one is saved: Forge.",
state.Caption(OtherServer, "other_typed"));
Assert.True(state.IsWarning(OtherServer, "other_typed"));
Assert.Equal("default token for other.example.test stored", state.Apply(move, "other_typed", _vault, settings));
Assert.Equal("other_typed", DefaultTokens.For(_vault, s_other));
Assert.Null(DefaultTokens.For(_vault, s_gitea));
Assert.Equal(OtherServer, settings.DefaultGiteaServer);
}
[Fact]
public void TwoServersOnOneHost_AreToldApartInWhatTheGroupSays()
{
ForgeEndpoint first = Place("https://git.example.test/first/");
var state = new SettingsGiteaState(first, hasSavedToken: true, dependentAccounts: []);
Assert.Equal(
"The saved token is for https://git.example.test/first/. Enter the token for https://git.example.test/second/, or put the address back.",
state.Plan("https://git.example.test/second", SettingsTokenState.Mask).Error);
Assert.Equal(
"The default token for https://git.example.test/second/ will be saved on Save, and the one for https://git.example.test/first/ removed.",
state.Caption("https://git.example.test/second", "typed"));
}
[Fact]
public void ATokenThatCannotBeStoredForAnotherServer_LeavesTheSavedOneWhereItWas()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge"]);
var vault = new VaultThatCannotStore(_vault);
Assert.Throws<IOException>(() => state.Apply(state.Plan(OtherServer, "other_typed"), "other_typed", vault, settings));
// The new token was to be stored first: the server that had the default still has it, and is still the default.
Assert.Equal(0, vault.Deleted);
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
Assert.Equal(Server, settings.DefaultGiteaServer);
}
[Fact]
public void ATokenTypedBehindTheMask_IsStoredWithoutTheMask()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: []);
string box = SettingsTokenState.Mask + "gitea_new"; // typed into the box without emptying it first
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), state.Plan(Server, box));
Assert.Equal("The default token will be replaced on Save.", state.Caption(Server, box));
Assert.Equal("default token for git.example.test replaced", state.Apply(state.Plan(Server, box), box, _vault, settings));
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
// A part of the mask deleted and nothing typed: no token was entered, so the saved one is removed, and it says so.
Assert.Equal(SettingsTokenAction.Remove, state.Plan(Server, SettingsTokenState.Mask[1..]).Action);
Assert.Equal("The default token will be removed on Save.", state.Caption(Server, SettingsTokenState.Mask[1..]));
// The same with another address, or none: what is left of the mask is no token for that server either.
Assert.True(state.Plan(OtherServer, SettingsTokenState.Mask[1..]).IsError);
Assert.Equal(SettingsTokenAction.Remove, state.Plan("", SettingsTokenState.Mask[1..]).Action);
Assert.Throws<ArgumentNullException>(() => state.Plan(Server, null!));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ATokenThatIsGoneWhenSaveIsPressed_IsNotTakenUp_AndTheSavedDefaultIsNotGivenUpForIt(bool anotherIsSaved)
{
_vault.Store(DefaultTokens.EntryFor(s_other), "other_saved");
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_lost");
var settings = new AppSettings { DefaultGiteaServer = anotherIsSaved ? OtherServer : null };
var state = new SettingsGiteaState(
anotherIsSaved ? s_other : null, hasSavedToken: anotherIsSaved, dependentAccounts: [],
holdsATokenFor: place => DefaultTokens.For(_vault, place) is not null);
SettingsGiteaPlan takeUp = state.Plan(Server, "");
Assert.True(takeUp.TakesUp);
// Between the address being typed and Save, the token is removed elsewhere.
_vault.Delete(DefaultTokens.EntryFor(s_gitea));
Assert.Equal(
"default token for git.example.test not taken up: it is no longer saved; nothing was changed",
state.Apply(takeUp, "", _vault, settings));
Assert.Equal(anotherIsSaved ? OtherServer : null, settings.DefaultGiteaServer);
Assert.Equal("other_saved", DefaultTokens.For(_vault, s_other));
}
[Fact]
public void ATokenTheVaultStillHolds_IsTakenUp_InPlaceOfTheDefaultThatIsSaved()
{
// The default is another server; the vault still holds a token for a server Settings lost sight of.
_vault.Store(DefaultTokens.EntryFor(s_other), "other_saved");
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_lost");
var settings = new AppSettings { DefaultGiteaServer = OtherServer };
var asked = new List<ForgeEndpoint>();
var state = new SettingsGiteaState(
s_other, hasSavedToken: true, dependentAccounts: ["Mirror"],
holdsATokenFor: place =>
{
asked.Add(place);
return DefaultTokens.For(_vault, place) is not null;
});
// A server that has no token: refused as before, the saved token is not carried there.
Assert.True(state.Plan("https://third.example.test", SettingsTokenState.Mask).IsError);
// The server whose token is there, with the box as it was or emptied: taken up, and it says what goes.
foreach(string box in (string[])[SettingsTokenState.Mask, ""])
{
SettingsGiteaPlan takeUp = state.Plan(Server, box);
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, s_gitea, "", TakesUp: true), takeUp);
Assert.Equal(
"A default token is already saved for https://git.example.test/. Save makes this server the Gitea default again, "
+ "and removes the one for https://other.example.test/; open Settings afterwards to replace or remove the token."
+ " 1 account uses it and will stop syncing until a new one is saved: Mirror.",
state.Caption(Server, box));
Assert.True(state.IsWarning(Server, box));
}
// The vault was asked once for each server, however often the plan was made.
Assert.Equal(2, asked.Count);
Assert.Equal(
"default token for git.example.test taken up again (it was already saved), and the one for other.example.test removed",
state.Apply(state.Plan(Server, SettingsTokenState.Mask), SettingsTokenState.Mask, _vault, settings));
Assert.Equal(Server, settings.DefaultGiteaServer);
Assert.Equal("gitea_lost", DefaultTokens.For(_vault, s_gitea)); // the token itself is not touched
Assert.Null(DefaultTokens.For(_vault, s_other)); // one default Gitea server
// With no account on the saved default, taking another one up is no warning.
var unused = new SettingsGiteaState(s_other, hasSavedToken: true, dependentAccounts: [], holdsATokenFor: _ => true);
Assert.False(unused.IsWarning(Server, SettingsTokenState.Mask));
}
[Fact]
public void ATokenTheVaultStillHolds_ForAServerSettingsLostSightOf_IsTakenUpAgain_WhenItsAddressIsEntered()
{
// settings.json no longer names the server (an older gclo wrote the file back); its token is still in the vault,
// and is found by whatever asks for the default of that server.
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings();
var state = new SettingsGiteaState(
savedPlace: null, hasSavedToken: false, dependentAccounts: [], holdsATokenFor: place => DefaultTokens.For(_vault, place) is not null);
Assert.False(state.HasSavedToken);
Assert.Equal("", state.InitialServer);
Assert.Equal("No default token saved for a Gitea server.", state.Caption("", ""));
// The address of a server that has none: nothing to do.
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, null, ""), state.Plan(OtherServer, ""));
Assert.Null(state.Apply(state.Plan(OtherServer, ""), "", _vault, settings));
Assert.Null(settings.DefaultGiteaServer);
// The address of the server whose token is there, and nothing typed: it is the default it was.
SettingsGiteaPlan takeUp = state.Plan("git.example.test", "");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, s_gitea, "", TakesUp: true), takeUp);
Assert.False(takeUp.IsError);
Assert.Equal(
"A default token is already saved for https://git.example.test/. Save makes this server the Gitea default again; "
+ "open Settings afterwards to replace or remove the token.",
state.Caption("git.example.test", ""));
Assert.False(state.IsWarning("git.example.test", ""));
Assert.Equal(
"default token for git.example.test taken up again (it was already saved)",
state.Apply(takeUp, "", _vault, settings));
Assert.Equal(Server, settings.DefaultGiteaServer);
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea)); // the token itself is not touched
// The dialog that opens next sees it as saved, and can replace or remove it.
var next = new SettingsGiteaState(settings.DefaultGiteaPlace(), hasSavedToken: true, dependentAccounts: []);
Assert.Equal(Server, next.InitialServer);
Assert.Equal(SettingsTokenAction.Remove, next.Plan(Server, "").Action);
// With a token typed for that address: an ordinary save, which replaces what was there.
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), state.Plan("git.example.test", "gitea_new"));
}
[Fact]
public void AGiteaTokenRemoved_ThatNoAccountUses_IsRemovedWithoutAWarning()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: []);
Assert.Equal("The default token will be removed on Save.", state.Caption(Server, ""));
Assert.False(state.IsWarning(Server, ""));
Assert.Equal("default token for git.example.test removed", state.Apply(state.Plan(Server, ""), "", _vault, settings));
}
[Fact]
public void AServerThatIsRememberedWithoutItsToken_IsNoDefault()
{
// The address is in the settings, the vault holds nothing for it (it was removed by hand, or on another machine).
var state = new SettingsGiteaState(s_gitea, hasSavedToken: false, dependentAccounts: []);
Assert.False(state.HasSavedToken);
Assert.Equal("", state.InitialServer);
Assert.Equal(SettingsTokenAction.Keep, state.Plan("", "").Action);
}
[Fact]
public void TheGiteaGroup_NeedsItsArguments()
{
var state = new SettingsGiteaState(savedPlace: null, hasSavedToken: false, dependentAccounts: []);
var nothing = new SettingsGiteaPlan(SettingsTokenAction.Keep, null, "");
Assert.Throws<ArgumentNullException>(() => state.Plan(null!, ""));
Assert.Throws<ArgumentNullException>(() => state.Plan("", null!));
Assert.Throws<ArgumentNullException>(() => state.Apply(null!, "", _vault, new AppSettings()));
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, null!, _vault, new AppSettings()));
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, "", null!, new AppSettings()));
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, "", _vault, null!));
// A token that is saved is saved for a server.
Assert.Throws<ArgumentException>(() => new SettingsGiteaState(savedPlace: null, hasSavedToken: true, dependentAccounts: []));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("http://git.example.test")] // a file edited by hand
public void ADefaultGiteaServerThatIsNoUsableAddress_IsNoPlace(string? stored)
{
Assert.Null(new AppSettings { DefaultGiteaServer = stored }.DefaultGiteaPlace());
}
// ---------------------------------------------------------------- the wizard
[Fact]
public async Task TheWizard_OffersTheDefaultTokenOfThePlaceThatIsChosen_AndUsesItThereOnly()
{
var sent = new List<(string Place, string Token)>();
AccountsStore store = NewStore();
var wizard = new AccountWizardViewModel(
store,
place => new FakeOrganizationLister
{
Handler = (token, _) =>
{
sent.Add((place.DisplayName, token));
return Task.FromResult<IReadOnlyList<string>>(["platform"]);
},
},
new AppSettings(),
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea_default" : place.Kind == ForgeKind.GitHub ? "ghp_default" : null);
// On GitHub: GitHub's default token.
Assert.True(wizard.HasDefaultToken);
Assert.True(wizard.OffersTokenChoice);
Assert.True(wizard.UseDefaultToken);
// On a server that has none: no choice, and a token of its own is asked for.
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = OtherServer;
Assert.True(await wizard.TryAdvanceAsync());
Assert.False(wizard.HasDefaultToken);
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
wizard.TokenChoiceIndex = 0; // the choice is not shown here; set all the same, there is nothing to send
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
wizard.TokenError);
Assert.Empty(sent);
// On the server that has one: offered, preselected, checked there, and the account uses it.
wizard.GoBack();
wizard.Server = Server;
Assert.True(await wizard.TryAdvanceAsync());
Assert.True(wizard.HasDefaultToken);
Assert.True(wizard.OffersTokenChoice);
Assert.True(wizard.UseDefaultToken);
Assert.Equal("Used by Quick Sync. Replacing it in Settings updates this account too.", wizard.DefaultTokenCaption);
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal([("git.example.test", "gitea_default")], sent);
wizard.Organization = "platform";
Assert.True(await wizard.TryAdvanceAsync());
wizard.TargetRoot = @"C:\mirror";
await wizard.SaveAsync();
Account saved = Assert.Single(NewStore().GetAll());
Assert.Equal(ForgeKind.Gitea, saved.Forge);
Assert.Equal(TokenSource.Default, saved.TokenSource);
Assert.Null(_vault.TryRetrieve(saved.TokenEntryId)); // it has no token of its own
}
[Fact]
public void TheWizard_EditingAnAccountOnTheDefaultTokenOfItsServer_CountsTheOthersOnThatServer()
{
AccountsStore store = NewStore();
Account forge = AccountOn(s_gitea, "Forge");
store.Save(forge, token: null);
store.Save(AccountOn(s_gitea, "Mirror"), token: null);
store.Save(AccountOn(ForgeEndpoint.GitHub, "Work"), token: null); // on GitHub's default: not counted here
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), forge, () => null,
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea_default" : null);
Assert.True(wizard.UseDefaultToken);
Assert.True(wizard.HasDefaultToken);
Assert.True(wizard.OffersTokenChoice);
Assert.Equal("Used by Quick Sync and 1 other account. Replacing it in Settings updates this account too.", wizard.DefaultTokenCaption);
}
[Fact]
public async Task AWizardForAnAccountOnAGiteaServer_ThatIsHandedGitHubsDefaultToken_OffersAndSendsNone()
{
// The older way to give a wizard "the default token" knows one token, GitHub's. It is no token of this server.
var sent = new List<string>();
var lister = new FakeOrganizationLister
{
Handler = (token, _) =>
{
sent.Add(token);
return Task.FromResult<IReadOnlyList<string>>(["platform"]);
},
};
AccountsStore store = NewStore();
Account own = AccountOn(s_gitea, "Own", TokenSource.Own);
store.Save(own, "gitea_own");
var editing = new AccountWizardViewModel(
store, lister, new AppSettings(), own, () => "gitea_own", defaultToken: () => "ghp_default");
Assert.False(editing.HasDefaultToken);
Assert.False(editing.OffersTokenChoice);
Assert.False(editing.UseDefaultToken);
// Set all the same (the choice is not shown): there is no default token for this server to send.
editing.TokenChoiceIndex = 0;
Assert.True(await editing.TryAdvanceAsync());
Assert.False(await editing.TryAdvanceAsync());
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
editing.TokenError);
Assert.Empty(sent);
// A connection on that server, saved as an account, with the very token GitHub's default is: it is the
// account's own token here, not "the default".
var seed = new AccountWizardSeed("ghp_default", "platform", @"C:\mirror", CreateOrgSubfolder: true, MaxConcurrency: 4, s_gitea);
var saving = new AccountWizardViewModel(store, lister, seed, defaultToken: () => "ghp_default");
Assert.False(saving.HasDefaultToken);
Assert.False(saving.UseDefaultToken);
// The same source on GitHub is GitHub's default token, as it always was.
var onGitHub = new AccountWizardViewModel(store, lister, seed with { Place = null }, defaultToken: () => "ghp_default");
Assert.True(onGitHub.HasDefaultToken);
Assert.True(onGitHub.UseDefaultToken);
}
[Fact]
public void AWizardForAnAccountWhosePlaceCannotBeUsed_HasNoDefaultToken()
{
AccountsStore store = NewStore();
Account later = AccountOn(s_gitea, "Later", TokenSource.Own) with { ForgeName = "GitLab" };
var asked = new List<ForgeEndpoint>();
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), later, () => null,
defaultTokenOf: place =>
{
asked.Add(place);
return "a_default";
});
// It is on no place this gclo knows: there is no place to ask the default token of, and none is asked for.
Assert.False(wizard.HasDefaultToken);
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
Assert.Empty(asked);
}
// ---------------------------------------------------------------- Quick Sync
[Fact]
public void QuickSync_IsToldOfAChangedDefaultToken_OnlyForThePlaceItIsOn()
{
using var workspace = new WorkspaceViewModel(
git: new FakeGitClient(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: new NullActivityLog(), wsl: new FakeWslCloner(),
listersOf: _ => (new FakeRepositoryLister(), new FakeOrganizationLister()),
defaultTokenOf: place => DefaultTokens.For(_vault, place));
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
workspace.IsOnGitea = true;
workspace.Server = Server;
Assert.Equal("gitea_default", workspace.Token); // filled in for the server on the card
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "gitea_default", current: "ghp_new");
workspace.OnDefaultTokenChanged(s_other, previous: "gitea_default", current: "other_new");
Assert.Equal("gitea_default", workspace.Token); // another place's default: not this card's
workspace.OnDefaultTokenChanged(s_gitea, previous: "gitea_default", current: "gitea_new");
Assert.Equal("gitea_new", workspace.Token);
workspace.OnDefaultTokenChanged(s_gitea, previous: "gitea_new", current: null);
Assert.Equal("", workspace.Token);
Assert.Throws<ArgumentNullException>(() => workspace.OnDefaultTokenChanged(null!, null, null));
}
/// <summary>A vault that reads and removes as the one it wraps, and cannot store.</summary>
private sealed class VaultThatCannotStore(InMemoryVault inner) : ITokenVault
{
public int Deleted { get; private set; }
public void Store(Guid accountId, string token) => throw new IOException("the credential store is not available");
public string? TryRetrieve(Guid accountId) => inner.TryRetrieve(accountId);
public void Delete(Guid accountId)
{
Deleted++;
inner.Delete(accountId);
}
public string DescribeEntry(Guid accountId) => inner.DescribeEntry(accountId);
}
}
File diff suppressed because it is too large. Load diff
@@ -139,4 +139,42 @@ public sealed class SettingsTokenStateTests
Assert.DoesNotContain(SettingsTokenState.Mask, caption);
}
}
[Fact]
public void WhatWasTypedAfterTheMask_IsTheTokenWithoutTheMask()
{
// Untouched, or typed into an empty box: as it is.
Assert.Equal(SettingsTokenState.Mask, SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask));
Assert.Equal("", SettingsTokenState.WithoutTheMask(""));
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask("ghp_typed"));
// Typed behind, before or into the mask without emptying the box first: the typing alone.
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask + "ghp_typed"));
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask("ghp_" + SettingsTokenState.Mask + "typed"));
// A part of the mask deleted: nothing was typed, and the rest of the mask is no token.
Assert.Equal("", SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask[1..]));
Assert.Throws<ArgumentNullException>(() => SettingsTokenState.WithoutTheMask(null!));
}
[Fact]
public void ATokenTypedBehindTheMask_IsATokenTyped_AndTheMaskIsNoPartOfIt()
{
var saved = new SettingsTokenState(hasSavedToken: true, dependentAccounts: ["Work"]);
Assert.Equal(SettingsTokenAction.Store, saved.Plan(SettingsTokenState.Mask + "ghp_typed"));
Assert.Equal(
"The default token will be replaced on Save. The 1 account that uses it switches to the new token too.",
saved.Caption(SettingsTokenState.Mask + "ghp_typed"));
// A part of the mask deleted and nothing typed: no token was entered. The saved one is removed, and it says so.
Assert.Equal(SettingsTokenAction.Remove, saved.Plan(SettingsTokenState.Mask[1..]));
Assert.True(saved.RemovalBreaksAccounts(SettingsTokenState.Mask[1..]));
Assert.Throws<ArgumentNullException>(() => saved.Plan(null!));
}
}
+2 -2
View File
@@ -27,8 +27,8 @@ public sealed class ShareInvitationTests
{
ShareInvitation invitation = Invitation();
Assert.Equal("Try gclo: clone and update every repository of a GitHub organization", invitation.Subject);
Assert.Contains("clones every repository of a GitHub organization or account", invitation.Body);
Assert.Equal("Try gclo: clone and update every repository of a GitHub or Gitea organization", invitation.Subject);
Assert.Contains("clones every repository of an organization or account on GitHub or on a Gitea server", invitation.Body);
Assert.Contains(Repository + "/releases/latest", invitation.Body);
Assert.Contains("gclo-stable-Setup.exe", invitation.Body);
Assert.Contains("scoop bucket add gclo " + Repository, invitation.Body);
+175
View File
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="WindowClaim"/>: one window per data folder (#216). The first claim
/// on a folder is the only one, a second is not and can ask the first to come to
/// the front, another folder is another claim, and a claim that is given up is free.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaimTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
[Fact]
public void TheFirstClaimOnAFolder_IsTheOnlyOne_AndASecondIsNot_UntilTheFirstIsGivenUp()
{
string folder = Path.Combine(_root, "data");
using(var first = WindowClaim.For(folder))
{
Assert.True(first.IsTheOnlyOne);
Assert.True(File.Exists(Path.Combine(folder, "window.lock"))); // the folder was made for it
// The same folder, spelled another way: the same claim.
using(var second = WindowClaim.For(folder.ToUpperInvariant() + Path.DirectorySeparatorChar))
{
Assert.False(second.IsTheOnlyOne);
}
// A second that came and went does not free the first's claim.
using var third = WindowClaim.For(folder);
Assert.False(third.IsTheOnlyOne);
// Another folder is another claim.
using var elsewhere = WindowClaim.For(Path.Combine(_root, "other"));
Assert.True(elsewhere.IsTheOnlyOne);
}
// Given up: the file is gone, and the next start is the only one again.
Assert.False(File.Exists(Path.Combine(folder, "window.lock")));
using var next = WindowClaim.For(folder);
Assert.True(next.IsTheOnlyOne);
}
[Fact]
public void ASecondStart_AsksTheWindowThatHoldsTheFolder_ToComeToTheFront()
{
string folder = Path.Combine(_root, "data");
using var first = WindowClaim.For(folder);
using var replaced = new ManualResetEventSlim();
using var asked = new SemaphoreSlim(0);
first.OnAskedToShow(replaced.Set);
first.OnAskedToShow(() => asked.Release()); // set again: the one before is not called any more
using(var second = WindowClaim.For(folder))
{
Assert.False(second.IsTheOnlyOne);
second.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked to show itself");
// Once for each request.
using(var again = WindowClaim.For(folder))
{
again.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked a second time");
Assert.False(replaced.IsSet);
}
[Fact]
public void ARequestForAnotherFolder_DoesNotReachThisWindow()
{
using var here = WindowClaim.For(Path.Combine(_root, "data"));
using var asked = new ManualResetEventSlim();
here.OnAskedToShow(asked.Set);
using(var elsewhere = WindowClaim.For(Path.Combine(_root, "other")))
{
elsewhere.AskTheOtherToShowItself();
}
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300)));
}
[Fact]
public void AFolderThatCannotBeWrittenTo_HoldsNoClaim_AndStopsNoWindow()
{
// The data folder's path is taken by a file: no folder can be made there, and no lock file in it.
Directory.CreateDirectory(_root);
string blocked = Path.Combine(_root, "a-file");
File.WriteAllText(blocked, "not a folder");
using var first = WindowClaim.For(blocked);
using var second = WindowClaim.For(blocked);
Assert.True(first.IsTheOnlyOne);
Assert.True(second.IsTheOnlyOne);
}
[Fact]
public void WhenTheRequestToComeToTheFrontCannotBeMade_TheClaimStands_AndNothingFails()
{
// The name of the event is taken by something that is no event (it could as well be one this process may not use).
string folder = Path.Combine(_root, "data");
using var inTheWay = new Mutex(initiallyOwned: false, $@"Local\gclo-show-{WindowClaim.KeyFor(folder)}");
using var first = WindowClaim.For(folder);
using var second = WindowClaim.For(folder);
using var asked = new ManualResetEventSlim();
first.OnAskedToShow(asked.Set);
second.AskTheOtherToShowItself();
Assert.True(first.IsTheOnlyOne);
Assert.False(second.IsTheOnlyOne);
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300))); // nothing is asked, and nothing is heard
}
[Fact]
public void TheKeyOfAFolder_IsTheSameForEverySpellingOfIt_AndAnotherForAnotherFolder()
{
string folder = Path.Combine(_root, "Data");
string key = WindowClaim.KeyFor(folder);
Assert.Equal(32, key.Length);
Assert.Matches("^[0-9A-F]{32}$", key);
Assert.Equal(key, WindowClaim.KeyFor(folder.ToLowerInvariant()), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(folder + Path.DirectorySeparatorChar), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(Path.Combine(_root, "other", "..", "Data")), StringComparer.Ordinal);
Assert.NotEqual(key, WindowClaim.KeyFor(Path.Combine(_root, "Data2")), StringComparer.Ordinal);
}
[Fact]
public void AClaim_NeedsAFolder_AndSomethingToDoWhenAsked()
{
Assert.Throws<ArgumentException>(() => WindowClaim.For(" "));
Assert.Throws<ArgumentNullException>(() => WindowClaim.For(null!));
Assert.Throws<ArgumentException>(() => WindowClaim.KeyFor(""));
using var claim = WindowClaim.For(Path.Combine(_root, "data"));
Assert.Throws<ArgumentNullException>(() => claim.OnAskedToShow(null!));
// Given up twice is given up.
claim.Dispose();
}
}
+6 -6
View File
@@ -2658,15 +2658,15 @@ public sealed class WorkspaceViewModelTests : IDisposable
foreach(WorkspaceViewModel vm in new[] { prefilled, typed, empty })
{
vm.OnDefaultTokenChanged(previous: "default-token-old-123", current: "default-token-new-456");
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "default-token-old-123", current: "default-token-new-456");
}
Assert.Equal("default-token-new-456", prefilled.Token);
Assert.Equal("typed-by-hand-0000000", typed.Token); // somebody's own token stays
Assert.Equal("", empty.Token); // the box was cleared by hand: it stays cleared
prefilled.OnDefaultTokenChanged(previous: "default-token-new-456", current: null);
empty.OnDefaultTokenChanged(previous: null, current: "default-token-first-1"); // first saved: nothing was there before
prefilled.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "default-token-new-456", current: null);
empty.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "default-token-first-1"); // first saved: nothing was there before
prefilled.RefreshAccountToken(); // Quick Sync has no source to read from: nothing happens
Assert.Equal("", prefilled.Token);
@@ -2684,7 +2684,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
vault.Store(AppSettings.DefaultTokenVaultId, "default-token-new-456");
// What the caller passes is not what an account uses: its source is the vault.
vm.OnDefaultTokenChanged(previous: "something else entirely", current: "also something else");
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "something else entirely", current: "also something else");
Assert.Equal("default-token-new-456", vm.Token);
}
@@ -2904,7 +2904,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
vault.Fails = true;
vm.RefreshAccountToken(); // must not throw: the shell and Sync all call this
vm.OnDefaultTokenChanged(previous: null, current: null);
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: null);
Assert.Equal("default-token-old-123", vm.Token);
Assert.Contains(log.Messages, m => m.Contains("its saved token could not be read", StringComparison.Ordinal));
@@ -2934,7 +2934,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
public async Task QuickSync_WhoseTokenBoxWasEmptied_FailsTheSyncInItsOwnWords()
{
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"));
vm.OnDefaultTokenChanged(previous: "token-1234567890", current: null); // the pre-filled default was removed
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "token-1234567890", current: null); // the pre-filled default was removed
await vm.SyncCommand.ExecuteAsync(null);
+1 -1
View File
@@ -13,7 +13,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>Core engine for gclo (Git Clone Large Organizations): lists every repository in a GitHub organization or user account and clones or fast-forward-pulls them all in parallel, with Windows path validation and rename/skip recovery.</Description>
<Description>Core engine for gclo (Git Clone Large Organizations): lists every repository of an organization or user account on GitHub or on a Gitea server and clones or fast-forward-pulls them all in parallel, with Windows path validation and rename/skip recovery.</Description>
<Authors>James Maes</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://git.kof22.com/KofTwentyTwo/gclo</RepositoryUrl>
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using FlaUI.Core.AutomationElements;
namespace gclo.UiTests;
/// <summary>
/// What the app shows the very first time it runs (#209), in a launched app of its
/// own: the other collections choose a place on Quick Sync's card as they go, and
/// this one has to see the card before anybody did.
/// </summary>
[Collection(nameof(UiFirstRunTests))]
public sealed class FirstRunTests : UiTestBase
{
public FirstRunTests(AppSession session)
: base(session)
{
}
// On a first run no place is chosen for the user, and the token box takes no
// token. Keyboard focus is not a choice: the window opens with the focus on the
// first control of the card, and a radio group that selected what has the focus
// had GitHub chosen before the user did anything. Choosing is an act.
[Fact]
public void OnAFirstRun_NoPlaceIsChosen_AndFocusAloneChoosesNone()
{
Assert.Equal("Connect", Session.WaitForElement("ConnectTitle").Name);
AutomationElement gitHub = Session.WaitForElement("ConnectPlaceGitHub");
AutomationElement gitea = Session.WaitForElement("ConnectPlaceGitea");
Assert.False(gitHub.AsRadioButton().IsChecked);
Assert.False(gitea.AsRadioButton().IsChecked);
Assert.False(Session.WaitForElement("ConnectTokenBox").IsEnabled);
foreach(AutomationElement focused in (AutomationElement[])[gitea, gitHub])
{
focused.Focus();
Session.WaitFor(() => focused.Properties.HasKeyboardFocus.ValueOrDefault ? focused : null, "the keyboard focus on a place");
Assert.False(gitHub.AsRadioButton().IsChecked);
Assert.False(gitea.AsRadioButton().IsChecked);
Assert.Equal("Connect", Session.WaitForElement("ConnectTitle").Name);
Assert.False(Session.WaitForElement("ConnectTokenBox").IsEnabled);
}
gitea.AsRadioButton().IsChecked = true;
Session.WaitForText("Connect to Gitea");
Assert.True(gitea.AsRadioButton().IsChecked);
Assert.False(gitHub.AsRadioButton().IsChecked);
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null,
"the token box to take a token once a place is chosen");
Assert.False(Session.LogContains("Organization lookup started"));
}
}
/// <summary>
/// A third collection (and therefore a third launched app), whose Quick Sync card
/// nobody touched; see <see cref="FirstRunTests"/>.
/// </summary>
[CollectionDefinition(nameof(UiFirstRunTests), DisableParallelization = true)]
public sealed class UiFirstRunTests : ICollectionFixture<AppSession>
{
}
+13 -3
View File
@@ -135,11 +135,21 @@ public sealed class RepoTableTests : UiTestBase
return;
}
AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox");
Session.EnterText(tokenBox, FixtureToken);
// The debounced org lookup is answered by the fixture (one organization); the
// Quick Sync asks where the repositories are first, and takes no token before
// that (#209). With the place chosen and the token entered nothing is sent yet:
// Find lists the organizations, answered by the fixture (one organization). The
// log line is the oracle, then the org is picked from the combo box's list so
// the selection commits to the view model's Organization, which enables Load.
Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true;
AutomationElement tokenBox = Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null,
"the token box to take a token once a place is chosen");
Session.EnterText(tokenBox, FixtureToken);
Assert.False(Session.LogContains("Organization lookup started"), "entering a token must not send it anywhere by itself");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null,
"the Find button to enable");
Session.ClickButton("Find");
Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20));
ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox();
Session.WaitFor(
+178 -2
View File
@@ -95,6 +95,46 @@ public sealed partial class SmokeTests : UiTestBase
// (4b) Settings holds a default token per forge (#212): GitHub, and a Gitea
// server with its address. A token for a server whose address cannot be
// used is not saved: the dialog says why and does not offer Save, and with
// the address corrected it does. Nothing is saved here: the dialog is canceled.
[Fact]
public void Settings_HasADefaultTokenPerForge_AndDoesNotSaveATokenForAnAddressItRefuses()
{
Session.InvokeMenuItem("File", "MenuSettings");
try
{
Session.WaitForText("Default tokens");
Assert.NotNull(Session.WaitForElement("SettingsDefaultTokenBox")); // GitHub
Session.EnterText(Session.WaitForElement("SettingsGiteaServerBox"), "http://git.example.test");
Session.EnterText(Session.WaitForElement("SettingsGiteaTokenBox"), "a-token-that-is-not-saved");
Session.WaitForText("The server address cannot be used: it has to begin with https://.");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button).And(cf.ByName("Save")).And(cf.ByClassName("Button")))
is { IsEnabled: false } save ? save : null,
"Save to be withheld");
Session.EnterText(Session.WaitForElement("SettingsGiteaServerBox"), "git.example.test");
Session.WaitForText("The default token for https://git.example.test/ will be saved on Save; Quick Sync fills it in when it is on that server.");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button).And(cf.ByName("Save")).And(cf.ByClassName("Button")))
is { IsEnabled: true } save ? save : null,
"Save to be offered again");
Session.ClickButton("Cancel");
Session.WaitForTextGone("Default tokens");
}
finally
{
Session.TryClickButton("Cancel"); // no-op when the happy path closed it
}
}
// Help -> Share gclo by email. Under the fixture the invitation is shown in the
// app instead of being handed to an email program, so the test can read it and
// nothing opens on the machine that runs the suite.
@@ -197,7 +237,11 @@ public sealed partial class SmokeTests : UiTestBase
[Fact]
public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent()
{
AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox");
// The box takes a token once a place was chosen (#209).
Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true;
AutomationElement tokenBox = Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null,
"the token box to take a token once a place is chosen");
Session.EnterText(tokenBox, "ghp_short");
Assert.NotNull(Session.WaitForElement("ConnectOrgBox"));
@@ -288,10 +332,142 @@ public sealed partial class SmokeTests : UiTestBase
// (10) Resizing below the minimum window size is intentionally not covered:
// (10) Quick Sync on a Gitea server (#209). The card asks where the repositories
// are; with Gitea chosen and an address entered its title names the server.
// Entering the token sends nothing: the organizations are listed when Find
// is pressed, and they are the ones of that server (the fixture answers for
// the place that was asked, offline). Nothing is loaded, and the card is put
// back on GitHub with an empty token box, so the other tests of this
// session find it as they expect.
[Fact]
public void QuickSync_OnAGiteaServer_ListsThatServersOrganizations_WhenAsked()
{
try
{
Session.WaitForElement("ConnectPlaceGitea").AsRadioButton().IsChecked = true;
Session.EnterText(Session.WaitForElement("ConnectServerBox"), "git.example.test");
AutomationElement tokenBox = Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null,
"the token box to take a token once a place is chosen");
Session.EnterText(tokenBox, "gclo-uitest-fixture-token");
// The address was taken with the token, as it was normalized, and the title names the server.
Session.WaitForText("Connect to git.example.test");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null,
"the Find button to enable");
Session.ClickButton("Find");
ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox();
Session.WaitFor(
() =>
{
orgBox.Expand();
return orgBox.Items.FirstOrDefault(i => string.Equals(i.Name, "fixture-gitea-org", StringComparison.Ordinal));
},
"the fixture's Gitea organization in the organization list");
orgBox.Collapse();
// The address typed over names no place, so Find is withheld; typed back, without
// the box being left, it is the server again and Find is offered.
AutomationElement serverBox = Session.WaitForElement("ConnectServerBox");
Session.EnterText(serverBox, "https://git.example.test/x");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: false } button ? button : null,
"Find to be withheld while the address is typed over");
Session.EnterText(serverBox, "https://git.example.test/");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null,
"Find to be offered again for the address that was typed back");
Assert.False(Session.App.HasExited);
}
finally
{
// Back to GitHub: choosing another place empties the token and the organization list.
Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true;
Session.WaitForText("Connect to GitHub");
}
}
// (11) The button at the top of the pane closes the pane to its icons and opens
// it again. It used to do nothing: the pane was set to stay open. The button
// names what it does next, in the language of Windows, so the oracle is that
// the name changes and changes back.
[Fact]
public void PaneButton_ClosesThePane_AndOpensItAgain()
{
string whenOpen = Session.WaitForElement("TogglePaneButton").Name;
try
{
Session.InvokeNavItem("TogglePaneButton");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("TogglePaneButton")) is { } button
&& !string.Equals(button.Name, whenOpen, StringComparison.Ordinal) ? button : null,
"the pane to close");
Assert.NotNull(Session.WaitForElement("NavQuickSync")); // still there, as its icon
}
finally
{
if(!string.Equals(Session.WaitForElement("TogglePaneButton").Name, whenOpen, StringComparison.Ordinal))
{
Session.InvokeNavItem("TogglePaneButton");
}
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("TogglePaneButton")) is { } button
&& string.Equals(button.Name, whenOpen, StringComparison.Ordinal) ? button : null,
"the pane to open again");
}
}
// (12) Resizing below the minimum window size is intentionally not covered:
// driving OS-level resize through UIA transforms is flaky across DPI
// scales, and the minimum is already enforced by OverlappedPresenter.
[GeneratedRegex(@"^Version \d+\.\d+\.\d+", RegexOptions.None, 1000)]
private static partial Regex VersionPattern();
// (12) One window per data folder (#216). gclo started again on the data folder
// of the open window opens no second window: the process ends by itself,
// and the window that is open comes out of the taskbar. The second process
// is the one this test starts, and the only one it ever ends.
[Fact]
public void StartingGcloAgain_OnTheSameDataFolder_EndsAtOnce_AndBringsTheOpenWindowBack()
{
FlaUI.Core.Patterns.IWindowPattern window = Session.MainWindow.Patterns.Window.Pattern;
window.SetWindowVisualState(FlaUI.Core.Definitions.WindowVisualState.Minimized);
Session.WaitFor(
() => window.WindowVisualState.Value == FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the window to be minimized");
var startInfo = new System.Diagnostics.ProcessStartInfo(Session.ExePath)
{
WorkingDirectory = Path.GetDirectoryName(Session.ExePath) ?? "",
};
startInfo.Environment["GCLO_DATA_DIR"] = Session.DataDirectory;
startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1";
using System.Diagnostics.Process second = System.Diagnostics.Process.Start(startInfo)!;
try
{
Assert.True(second.WaitForExit(TimeSpan.FromSeconds(30)), "the second gclo did not end by itself");
Assert.Equal(0, second.ExitCode);
Session.WaitFor(
() => window.WindowVisualState.Value != FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the open window to come back from the taskbar");
Assert.False(Session.App.HasExited);
}
finally
{
if(!second.HasExited)
{
second.Kill();
}
}
}
}
+9 -10
View File
@@ -38,25 +38,24 @@ public sealed class AccountTokenResolver
public string? TryResolve(Account account)
{
ArgumentNullException.ThrowIfNull(account);
if(account.TokenSource == TokenSource.Default && account.Forge != ForgeKind.GitHub)
if(account.TokenSource == TokenSource.Default)
{
///////////////////////////////////////////////////////////////////////////
// The default token is a GitHub token. It is never handed to an account //
// on another forge, however such an account came to say it uses it. //
// The default token of the place the account is on, and of no other: it //
// is looked up by that place. An account whose place cannot be used has //
// none. //
///////////////////////////////////////////////////////////////////////////
return null;
return account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? DefaultTokens.For(_vault, place) : null;
}
string? token = account.TokenSource == TokenSource.Default
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
: _vault.TryRetrieve(account.TokenEntryId);
string? token = _vault.TryRetrieve(account.TokenEntryId);
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>True when the vault holds a default token for <paramref name="place"/>.</summary>
public bool HasDefaultTokenFor(ForgeEndpoint place) => DefaultTokens.For(_vault, place) is not null;
@@ -73,7 +72,7 @@ public sealed class AccountTokenResolver
{
return account.Forge == ForgeKind.GitHub
? "uses the default token, but no default token is saved (desktop app: Settings)"
: "is set to use the default token, which is a GitHub token and is not used for another forge; give the account a token of its own";
: $"uses the default token for {account.DescribePlace()}, but none is saved for it (desktop app: Settings)";
}
return $"has no token in {_vault.DescribeEntry(account.TokenEntryId)}";
}
+7 -2
View File
@@ -3,6 +3,9 @@
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
@@ -16,15 +19,17 @@ namespace gclo.ViewModels;
/// <param name="TargetRoot">The workspace's target folder (not the org subfolder).</param>
/// <param name="CreateOrgSubfolder">Whether clones go under TargetRoot\Organization.</param>
/// <param name="MaxConcurrency">The workspace's parallel clone count.</param>
/// <param name="Place">Where the connection is: the place the token was used with. Null means GitHub.</param>
public sealed record AccountWizardSeed(
string Token,
string Organization,
string TargetRoot,
bool CreateOrgSubfolder,
int MaxConcurrency)
int MaxConcurrency,
ForgeEndpoint? Place = null)
{
/// <summary>Redacts the token: this record must never print it.</summary>
public override string ToString() =>
$"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, "
+ $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}";
+ $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency}, Place = {Place ?? ForgeEndpoint.GitHub} }}";
}
+70 -47
View File
@@ -57,11 +57,13 @@ public sealed partial class AccountWizardViewModel : ObservableObject
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).
/// Fetches the default token saved in Settings for a place on demand (null, or
/// returning null, when none is saved for it). Read when step 2 validates or the
/// account is saved with "Use the default token"; never copied into
/// <see cref="Token"/> (#102). Always asked for the place the account is on: a
/// default token is not used for another place.
/// </summary>
private readonly Func<string?>? _defaultToken;
private readonly Func<ForgeEndpoint, string?>? _defaultTokenOf;
@@ -70,8 +72,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <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);
/// <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.
/// <paramref name="defaultToken"/> fetches GitHub's default token from Settings
/// the same way and, when it returns one, offers "Use the default token" on step
/// 2 of a GitHub account; <paramref name="defaultTokenOf"/> fetches the default
/// token of a place, and is what a caller that knows of other places gives. The
/// wizard only ever asks for the default token of the place its account is on.
/// </summary>
public AccountWizardViewModel(
AccountsStore store,
@@ -80,8 +85,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Account? existing = null,
Func<string?>? storedToken = null,
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, listerOf: null, existing, storedToken, defaultToken, log)
Func<string?>? defaultToken = null,
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
: this(store, orgLister, listerOf: null, existing, storedToken, defaultTokenOf ?? OfGitHubOnly(defaultToken), log)
{
ArgumentNullException.ThrowIfNull(defaults);
@@ -118,8 +124,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Func<ForgeEndpoint, IOrganizationLister> listerOf,
AppSettings defaults,
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, ListerOfGitHub(listerOf), listerOf, existing: null, storedToken: null, defaultToken, log)
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
: this(store, ListerOfGitHub(listerOf), listerOf, existing: null, storedToken: null, defaultTokenOf, log)
{
ArgumentNullException.ThrowIfNull(defaults);
TargetRoot = defaults.DefaultTargetFolder;
@@ -137,18 +143,23 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// </summary>
public AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, listerOf: null, existing: null, storedToken: null, defaultToken, log)
Func<string?>? defaultToken = null,
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
: this(store, orgLister, listerOf: null, existing: null, storedToken: null, defaultTokenOf ?? OfGitHubOnly(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
// The account is where the connection was: its token was used with that place.
_chosen = seed.Place ?? ForgeEndpoint.GitHub;
// A Quick Sync connection that runs on the default token of its place 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);
string ofThePlace = DefaultTokenNow();
HasDefaultToken = ofThePlace.Length > 0;
UseDefaultToken = HasDefaultToken && string.Equals(seed.Token.Trim(), ofThePlace, StringComparison.Ordinal);
Organization = seed.Organization;
TargetRoot = seed.TargetRoot;
CreateOrgSubfolder = seed.CreateOrgSubfolder;
@@ -166,9 +177,20 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// A source that knows one default token only knows GitHub's, which is what
/// "the default token" was before a server could have one: it is given for
/// GitHub and for no other place. A wizard for an account on a Gitea server is
/// given a source per place (<c>defaultTokenOf</c>) instead.
/// </summary>
private static Func<ForgeEndpoint, string?>? OfGitHubOnly(Func<string?>? defaultToken)
=> defaultToken is null ? null : place => place.Server is null ? defaultToken() : null;
private AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, Func<ForgeEndpoint, IOrganizationLister>? listerOf,
Account? existing, Func<string?>? storedToken, Func<string?>? defaultToken, IActivityLog? log)
Account? existing, Func<string?>? storedToken, Func<ForgeEndpoint, string?>? defaultTokenOf, IActivityLog? log)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
@@ -179,11 +201,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
_existing = existing;
_existingPlace = existing is not null && existing.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null;
_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 && existing.Forge == ForgeKind.GitHub;
_defaultTokenOf = defaultTokenOf;
// Decided when the place is: the choice is offered only when a default token
// exists for it then, and the value itself is fetched again when a step needs it.
HasDefaultToken = DefaultTokenNow().Length > 0;
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
Step = 1;
NameError = "";
@@ -227,7 +249,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// Whether step 1 asks where the account lives. Only for a new account in a
/// wizard that was given a lister per place; an account being edited stays where
/// it is, and a Quick Sync connection saved as an account is a GitHub connection.
/// it is, and a Quick Sync connection saved as an account is on the place of that connection.
/// </summary>
public bool CanChoosePlace => _listerOf is not null;
@@ -330,10 +352,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
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.
/// True when a default token was saved in Settings for the place the account is
/// on, when the wizard opened or when step 1 took the place: that is when step 2
/// offers the choice between it and a token of the account's own.
/// </summary>
public bool HasDefaultToken { get; }
public bool HasDefaultToken { get; private set; }
/// <summary>
/// Step 2's choice: true means the account resolves to the default token from
@@ -356,11 +379,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// default token while none is saved, which otherwise could not be given another.
/// </summary>
/// <remarks>
/// Never for an account that is not on GitHub: the default token is a GitHub
/// token, and such an account has a token of its own.
/// The default token is the one of the place the account is on. A place that has
/// none offers no choice: the account has a token of its own.
/// </remarks>
public bool OffersTokenChoice
=> PlaceKind == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
public bool OffersTokenChoice => HasDefaultToken || _existing?.UsesDefaultToken == true;
/// <summary>
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
@@ -385,7 +407,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
return "No default token is saved right now. Save one in Settings, or give this account a token of its own.";
}
int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0);
int others = (Place is { } place ? _store.UsingDefaultTokenOf(place).Count : 0) - (_existing?.UsesDefaultToken == true ? 1 : 0);
string shared = others switch
{
0 => "Used by Quick Sync.",
@@ -440,6 +462,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// The default token saved for the place the account is on, as it is right now;
/// empty when there is none, and for an account whose place cannot be used.
/// </summary>
private string DefaultTokenNow() => Place is { } place ? _defaultTokenOf?.Invoke(place)?.Trim() ?? "" : "";
/// <summary>The kind of forge the account is on: the stored one for an edit, the chosen one for a new account.</summary>
private ForgeKind PlaceKind => _existing?.Forge ?? _chosen.Kind;
@@ -519,22 +549,12 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// An edit with the box left empty validates the STORED token, fetched
// here and used for this one call only.
string candidate;
if(UseDefaultToken && PlaceKind != ForgeKind.GitHub)
{
///////////////////////////////////////////////////////////////////
// The choice is not shown for such an account. Whatever set it, //
// the default token is a GitHub token and is not sent anywhere //
// else. //
///////////////////////////////////////////////////////////////////
TokenError = "The default token is a GitHub token. This account needs a token of its own.";
return false;
}
if(UseDefaultToken)
{
// 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() ?? "";
// The default token of the place the account is on, and of no
// other, fetched for this one call only; the box stays untouched
// (and disabled) so it is never shown or retained.
candidate = DefaultTokenNow();
if(candidate.Length == 0)
{
TokenError = "The default token is no longer saved. Save one in Settings, "
@@ -649,7 +669,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// Another place than the one the wizard was on. What was entered for the //
// other place is not carried over: a token typed for one server is not //
// sent to another, and an organization of one is not one of the other. The //
// default token is a GitHub token, so it is not the choice anywhere else. //
// default token on offer is the one saved for the new place, if any. //
//////////////////////////////////////////////////////////////////////////////
_orgLister = listerOf(place); // first: if there is no lister for the place, the wizard stays on the one it has
_chosen = place;
@@ -657,7 +677,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
TokenError = "";
Organizations.Clear();
Organization = "";
UseDefaultToken = place.Kind == ForgeKind.GitHub && HasDefaultToken;
HasDefaultToken = DefaultTokenNow().Length > 0;
UseDefaultToken = HasDefaultToken;
OnPropertyChanged(nameof(HasDefaultToken));
OnPropertyChanged(nameof(DefaultTokenCaption));
OnPropertyChanged(nameof(PlaceName));
OnPropertyChanged(nameof(OffersTokenChoice));
OnPropertyChanged(nameof(TokenHeader));
+9 -12
View File
@@ -349,12 +349,18 @@ public sealed class AccountsStore
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
public int CountUsingDefaultToken()
/// <summary>
/// The accounts that resolve to the default token of <paramref name="place"/>
/// (Settings shows what a removal breaks, the wizard how many share it).
/// </summary>
public IReadOnlyList<Account> UsingDefaultTokenOf(ForgeEndpoint place)
{
ArgumentNullException.ThrowIfNull(place);
lock(_gate)
{
return _accounts.Count(a => a.UsesDefaultToken);
return _accounts
.Where(a => a.UsesDefaultToken && a.TryGetEndpoint(out ForgeEndpoint? where, out _) && where.Equals(place))
.ToList();
}
}
@@ -604,8 +610,6 @@ public sealed class AccountsStore
/// A token that was saved for one server is not sent to another because somebody
/// edited the address; the account is deleted and added again, with its token
/// entered for the new place.</item>
/// <item>The default token is a GitHub token: an account on another forge has a
/// token of its own.</item>
/// </list>
/// </remarks>
private Account WithItsForgeChecked(Account account)
@@ -636,13 +640,6 @@ public sealed class AccountsStore
+ "Delete the account and add it again for the other one.",
nameof(account));
}
if(account.UsesDefaultToken && account.Forge != ForgeKind.GitHub)
{
throw new ArgumentException(
$"Account '{account.Name}' is on {endpoint.DisplayName}; the default token is a GitHub token, so the account needs a token of its own.",
nameof(account));
}
return account;
}
+139
View File
@@ -37,6 +37,39 @@ public sealed class AppSettings
/// </summary>
public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11");
/// <summary>
/// The address of the Gitea server a default token is saved for (Settings); null
/// when there is none. The token itself is in the vault, under an entry named
/// after this address (<see cref="DefaultTokens.EntryFor"/>), never in this file.
/// </summary>
public string? DefaultGiteaServer { get; set; }
/// <summary>
/// The workspace that was open last: an account's id, or <see cref="StartWorkspace.QuickSync"/>.
/// Null when nothing is recorded yet. See <see cref="StartWorkspace.Choose"/>.
/// </summary>
public Guid? LastWorkspace { get; set; }
/// <summary>
/// The forge Quick Sync last connected to, by name; null for GitHub. With
/// <see cref="QuickSyncServer"/>, <see cref="QuickSyncOrganization"/>,
/// <see cref="QuickSyncTargetFolder"/> and <see cref="QuickSyncCreateOrgSubfolder"/>
/// it is what <see cref="RememberedQuickSync"/> gives back. No token is among them.
/// </summary>
public string? QuickSyncForge { get; set; }
/// <summary>The address of the Gitea server Quick Sync last connected to; null for GitHub.</summary>
public string? QuickSyncServer { get; set; }
/// <summary>The organization Quick Sync last loaded; empty when none.</summary>
public string QuickSyncOrganization { get; set; } = "";
/// <summary>The target folder of Quick Sync's last connection; empty when none.</summary>
public string QuickSyncTargetFolder { get; set; } = "";
/// <summary>Whether Quick Sync's last connection cloned under an organization subfolder.</summary>
public bool QuickSyncCreateOrgSubfolder { get; set; }
/// <summary>Folder pre-filled as the sync target. Empty means "no default".</summary>
public string DefaultTargetFolder { get; set; } = "";
@@ -86,6 +119,7 @@ public sealed class AppSettings
{
// Settings must never prevent startup; fall through to defaults.
log?.Error($"Settings could not be loaded ({ex.Message}); the defaults are used.", ex);
return new AppSettings { CouldNotBeRead = true };
}
return new AppSettings();
@@ -93,6 +127,60 @@ public sealed class AppSettings
/// <summary>
/// True when the settings file was there and could not be read, so these are the
/// defaults in its place. What the app remembers by itself is then not written
/// (<see cref="SaveWhatWasRemembered"/>): the file is left for the user to mend,
/// as it was before the app remembered anything. Saving the Settings dialog
/// still writes.
/// </summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool CouldNotBeRead { get; private set; }
/// <summary>True once this window recorded a Quick Sync connection itself (<see cref="RememberQuickSync"/>).</summary>
private bool _rememberedAConnection;
/// <summary>
/// Writes what the app remembered by itself (the workspace that was open, and
/// Quick Sync's connection if this window made one), not what the user saved in
/// Settings. Only those values are written, over the file as it is on disk now:
/// another gclo window may have saved settings since this one read them, and a
/// window that closes does not put its older copy of them back. Does nothing
/// when the file could not be read, at start or now: that file is not written
/// over with defaults on the app's own account.
/// </summary>
public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null)
{
if(CouldNotBeRead)
{
return false;
}
AppSettings onDisk = Load(log: null, directory);
if(onDisk.CouldNotBeRead)
{
log?.Info("What the app remembers was not written: the settings file cannot be read just now.");
return false;
}
onDisk.LastWorkspace = LastWorkspace;
if(_rememberedAConnection)
{
onDisk.QuickSyncForge = QuickSyncForge;
onDisk.QuickSyncServer = QuickSyncServer;
onDisk.QuickSyncOrganization = QuickSyncOrganization;
onDisk.QuickSyncTargetFolder = QuickSyncTargetFolder;
onDisk.QuickSyncCreateOrgSubfolder = QuickSyncCreateOrgSubfolder;
}
return onDisk.Save(log, directory);
}
/// <summary>How long a save tries to put the new file in place while something has the old one open.</summary>
private static readonly TimeSpan s_savePatience = TimeSpan.FromSeconds(1);
@@ -170,10 +258,61 @@ public sealed class AppSettings
/// <summary>
/// The Gitea server a default token is saved for, or null when there is none or
/// what is stored is no address that can be used (a file edited by hand).
/// </summary>
public ForgeEndpoint? DefaultGiteaPlace()
=> ForgeEndpoint.TryCreate(ForgeKind.Gitea, DefaultGiteaServer, out ForgeEndpoint? place, out _) ? place : null;
/// <summary>
/// Records a Quick Sync connection that worked, so the card opens the same way
/// next time. The caller saves. Never a token.
/// </summary>
public void RememberQuickSync(QuickSyncConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
QuickSyncForge = connection.Place.Kind == ForgeKind.GitHub ? null : connection.Place.Kind.ToString();
QuickSyncServer = connection.Place.Server?.AbsoluteUri;
QuickSyncOrganization = connection.Organization.Trim();
QuickSyncTargetFolder = connection.TargetFolder.Trim();
QuickSyncCreateOrgSubfolder = connection.CreateOrgSubfolder;
_rememberedAConnection = true;
}
/// <summary>
/// The Quick Sync connection that was recorded. Null when none was (a first
/// run: a connection that worked has an organization), and when what is stored
/// names a place that cannot be used (a file edited by hand, or a forge of a
/// later version). The card then opens with no place chosen.
/// </summary>
public QuickSyncConnection? RememberedQuickSync()
{
if(QuickSyncOrganization.Length == 0)
{
return null;
}
ForgeKind? kind = QuickSyncForge is null ? ForgeKind.GitHub
: string.Equals(QuickSyncForge, nameof(ForgeKind.Gitea), StringComparison.OrdinalIgnoreCase) ? ForgeKind.Gitea
: null;
return kind is ForgeKind known && ForgeEndpoint.TryCreate(known, QuickSyncServer, out ForgeEndpoint? place, out _)
? new QuickSyncConnection(place, QuickSyncOrganization, QuickSyncTargetFolder, QuickSyncCreateOrgSubfolder)
: null;
}
private void Sanitize()
{
// The JSON deserializer can assign null despite the non-nullable annotation.
DefaultTargetFolder ??= "";
QuickSyncOrganization ??= "";
QuickSyncTargetFolder ??= "";
DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency);
// A file written before this setting existed has no such field, and the
// property then keeps its default; whatever a file does say is brought into range.
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// The default tokens saved in Settings: one for GitHub, and one for a Gitea
/// server. Quick Sync fills in the one of the place it is on, and an account that
/// uses "the default token" gets the one of the place it is on.
/// </summary>
/// <remarks>
/// A default token belongs to one place and is never used for another. That is not
/// a check somebody has to remember to make: the vault entry is named after the
/// place (for a Gitea server, after its address), so asking for the default token
/// of a place finds the one that was saved for exactly that place, or none.
/// </remarks>
public static class DefaultTokens
{
/// <summary>
/// The vault entry that holds the default token of <paramref name="place"/>:
/// for GitHub the one it always was; for a Gitea server one made from the
/// server's address. Never to be changed: saved tokens are found by it.
/// </summary>
public static Guid EntryFor(ForgeEndpoint place)
{
ArgumentNullException.ThrowIfNull(place);
if(place.Server is null)
{
return AppSettings.DefaultTokenVaultId;
}
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"gclo default token\n{place.Kind}\n{place.Server.AbsoluteUri}"));
return new Guid(hash.AsSpan(0, 16));
}
/// <summary>The default token saved for <paramref name="place"/>, or null when none is saved for it.</summary>
public static string? For(ITokenVault vault, ForgeEndpoint place)
{
ArgumentNullException.ThrowIfNull(vault);
string? token = vault.TryRetrieve(EntryFor(place));
return string.IsNullOrEmpty(token) ? null : token;
}
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// What a Quick Sync connection that worked consisted of, without its token: the
/// place, the organization and where the clones go. It is what the app remembers
/// between runs, so Quick Sync opens the way it was left. A token is never part of
/// it: Quick Sync keeps none.
/// </summary>
/// <param name="Place">GitHub, or the Gitea server.</param>
/// <param name="Organization">The organization (or user) that was loaded.</param>
/// <param name="TargetFolder">The target folder; empty when none was chosen.</param>
/// <param name="CreateOrgSubfolder">Whether clones go under TargetFolder\Organization.</param>
public sealed record QuickSyncConnection(ForgeEndpoint Place, string Organization, string TargetFolder, bool CreateOrgSubfolder);
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// What Save does with the Gitea group of the Settings dialog
/// (<see cref="SettingsGiteaState.Plan"/>): keep, store or remove the default
/// token, for which server, or the reason it cannot be saved as it stands.
/// </summary>
/// <param name="Action">What happens to the default token.</param>
/// <param name="Place">The server the action is about; null when nothing is stored or removed.</param>
/// <param name="Error">Why the group cannot be saved as it stands; empty when it can.</param>
/// <param name="TakesUp">
/// True when the vault already holds a default token for <paramref name="Place"/>
/// that Settings had lost sight of: Save makes that server the Gitea default again,
/// without a token being typed or stored.
/// </param>
public sealed record SettingsGiteaPlan(SettingsTokenAction Action, ForgeEndpoint? Place, string Error, bool TakesUp = false)
{
/// <summary>True when the dialog must not be saved: the server address or the token needs correcting first.</summary>
public bool IsError => Error.Length > 0;
}
+289
View File
@@ -0,0 +1,289 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// The Gitea group of the Settings dialog, as state the dialog only renders: the
/// address of the server and its default token. It is the counterpart of
/// <see cref="SettingsTokenState"/> (the GitHub token), with the one thing a Gitea
/// default has on top: the token is for one server, so the address and the token
/// are saved, replaced and removed together.
/// </summary>
/// <remarks>
/// <para>
/// The saved token is never carried to another address. Changing the address
/// asks for the token of the new server; the token of the old one is removed.
/// </para>
/// <para>
/// Which server has the default is written in settings.json, the token is in the
/// vault under an entry named after that server. The two can come apart: the
/// settings file could not be written, or read, or an older gclo wrote it back
/// without the address. The token is then still found by everything that asks for
/// the default of that server, while this dialog shows none. So that it can be
/// seen and removed again, an address that is entered here and already has a
/// default token in the vault is taken up as the default it is. There is one
/// default Gitea server: when another one is saved, taking a server up removes
/// that one's token, as typing a token for another server does.
/// </para>
/// </remarks>
public sealed class SettingsGiteaState
{
private readonly ForgeEndpoint? _savedPlace;
private readonly SettingsTokenState _token;
private readonly Func<ForgeEndpoint, bool> _holdsATokenFor;
/// <summary>What <see cref="_holdsATokenFor"/> answered, by server: the vault is asked once for each.</summary>
private readonly Dictionary<ForgeEndpoint, bool> _held = [];
/// <summary>
/// <paramref name="savedPlace"/>: the server a default token is saved for, or
/// null; <paramref name="hasSavedToken"/>: whether the vault holds that token;
/// <paramref name="dependentAccounts"/>: the names of the accounts on that
/// server that use the default token, which a removal would break;
/// <paramref name="holdsATokenFor"/>: whether the vault holds a default token for
/// a server (see the remarks), none when it is not given.
/// </summary>
public SettingsGiteaState(
ForgeEndpoint? savedPlace, bool hasSavedToken, IReadOnlyList<string> dependentAccounts, Func<ForgeEndpoint, bool>? holdsATokenFor = null)
{
if(hasSavedToken && savedPlace is null)
{
throw new ArgumentException("A token that is saved is saved for a server.", nameof(savedPlace));
}
_savedPlace = savedPlace;
_token = new SettingsTokenState(hasSavedToken, dependentAccounts);
_holdsATokenFor = holdsATokenFor ?? (_ => false);
}
/// <summary>True when a default token is saved for a server.</summary>
public bool HasSavedToken => _token.HasSavedToken;
/// <summary>What the address box shows at first: the saved server's address, or nothing.</summary>
public string InitialServer => HasSavedToken ? _savedPlace?.Server?.AbsoluteUri ?? "" : "";
/// <summary>What the token box shows at first: the mask when a token is saved, empty otherwise.</summary>
public string InitialBoxValue => _token.InitialBoxValue;
/// <summary>True when "Remove saved token" applies: a token is saved and the box still shows the mask.</summary>
public bool CanOfferRemoval(string boxValue) => _token.CanOfferRemoval(boxValue);
/// <summary>What Save will do with the address box and the token box as they stand.</summary>
public SettingsGiteaPlan Plan(string server, string boxValue)
{
ArgumentNullException.ThrowIfNull(server);
boxValue = SettingsTokenState.WithoutTheMask(boxValue);
bool typed = boxValue.Length > 0 && !string.Equals(boxValue, SettingsTokenState.Mask, StringComparison.Ordinal);
if(string.IsNullOrWhiteSpace(server))
{
if(typed)
{
return Refused("Enter the address of the server this token is for.");
}
// No address is no default: what was saved goes with it.
return HasSavedToken ? new SettingsGiteaPlan(SettingsTokenAction.Remove, _savedPlace, "") : Nothing;
}
if(!ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out string? reason))
{
return Refused(char.ToUpperInvariant(reason[0]) + reason[1..] + ".");
}
if(!HasSavedToken)
{
if(typed)
{
return new SettingsGiteaPlan(SettingsTokenAction.Store, place, "");
}
// Nothing typed: nothing to do, unless the vault holds a default token for this very server already.
return HoldsATokenFor(place) ? new SettingsGiteaPlan(SettingsTokenAction.Keep, place, "", TakesUp: true) : Nothing;
}
if(place.Equals(_savedPlace))
{
SettingsTokenAction action = _token.Plan(boxValue);
return action == SettingsTokenAction.Keep ? Nothing : new SettingsGiteaPlan(action, place, "");
}
///////////////////////////////////////////////////////////////////////////////
// Another server than the one the token was saved for. The saved token is //
// not sent there: the token of the new server has to be entered, unless the //
// vault holds a default token for that very server already (see the //
// remarks), which is then taken up in place of the saved one. //
///////////////////////////////////////////////////////////////////////////////
if(typed)
{
return new SettingsGiteaPlan(SettingsTokenAction.Store, place, "");
}
return HoldsATokenFor(place)
? new SettingsGiteaPlan(SettingsTokenAction.Keep, place, "", TakesUp: true)
: Refused($"The saved token is for {Address(_savedPlace!)}. Enter the token for {Address(place)}, or put the address back.");
}
/// <summary>
/// Whether the vault holds a default token for <paramref name="place"/>. Asked
/// once for each server, however often the boxes change: the plan is made again
/// for every character that is typed.
/// </summary>
private bool HoldsATokenFor(ForgeEndpoint place)
{
if(!_held.TryGetValue(place, out bool held))
{
held = _holdsATokenFor(place);
_held[place] = held;
}
return held;
}
/// <summary>
/// A server as these captions name it: by its whole address, not its host
/// alone. Two servers can share a host, and the captions tell one from the other.
/// </summary>
private static string Address(ForgeEndpoint place) => place.Server?.AbsoluteUri ?? place.DisplayName;
/// <summary>The caption under the group for the boxes as they stand; names what Save will do.</summary>
public string Caption(string server, string boxValue)
{
SettingsGiteaPlan plan = Plan(server, boxValue);
if(plan.IsError)
{
return plan.Error;
}
switch(plan.Action)
{
case SettingsTokenAction.Store when !HasSavedToken:
return $"The default token for {Address(plan.Place!)} will be saved on Save; Quick Sync fills it in when it is on that server.";
case SettingsTokenAction.Store when plan.Place!.Equals(_savedPlace):
return "The default token will be replaced on Save." + _token.RotationNote();
case SettingsTokenAction.Store:
return $"The default token for {Address(plan.Place!)} will be saved on Save, and the one for {Address(_savedPlace!)} removed."
+ _token.RemovalWarning();
case SettingsTokenAction.Remove:
return "The default token will be removed on Save." + _token.RemovalWarning();
case SettingsTokenAction.Keep when plan.TakesUp && HasSavedToken:
return $"A default token is already saved for {Address(plan.Place!)}. Save makes this server the Gitea default again, "
+ $"and removes the one for {Address(_savedPlace!)}; open Settings afterwards to replace or remove the token."
+ _token.RemovalWarning();
case SettingsTokenAction.Keep when plan.TakesUp:
return $"A default token is already saved for {Address(plan.Place!)}. Save makes this server the Gitea default again; "
+ "open Settings afterwards to replace or remove the token.";
default:
return HasSavedToken
? $"A default token is saved for {Address(_savedPlace!)}" + _token.UsedBy() + ". Type to replace it; clear the box to remove it."
: "No default token saved for a Gitea server.";
}
}
/// <summary>
/// True when the caption is a warning: the group cannot be saved as it stands, or
/// Save would take a token away that accounts depend on.
/// </summary>
public bool IsWarning(string server, string boxValue)
{
SettingsGiteaPlan plan = Plan(server, boxValue);
bool takesTheSavedTokenAway = plan.Action == SettingsTokenAction.Remove
|| ((plan.Action == SettingsTokenAction.Store || plan.TakesUp) && HasSavedToken && !plan.Place!.Equals(_savedPlace));
return plan.IsError || (takesTheSavedTokenAway && _token.DependentCount > 0);
}
/// <summary>
/// Does what <paramref name="plan"/> says: stores or removes the token in
/// <paramref name="vault"/> and records the server in <paramref name="settings"/>
/// (the caller saves them). The token of a server that is replaced by another is
/// removed. Returns what changed, for the log, without the token; null when
/// nothing did.
/// </summary>
public string? Apply(SettingsGiteaPlan plan, string boxValue, ITokenVault vault, AppSettings settings)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(boxValue);
ArgumentNullException.ThrowIfNull(vault);
ArgumentNullException.ThrowIfNull(settings);
if(plan.IsError)
{
throw new InvalidOperationException("The Gitea default cannot be saved as it stands: " + plan.Error);
}
switch(plan.Action)
{
case SettingsTokenAction.Store:
// The new one first: if it cannot be stored, the old server keeps its token and stays the default.
vault.Store(DefaultTokens.EntryFor(plan.Place!), SettingsTokenState.WithoutTheMask(boxValue));
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
if(HasSavedToken && !plan.Place.Equals(_savedPlace))
{
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
}
return HasSavedToken && plan.Place.Equals(_savedPlace)
? $"default token for {plan.Place.DisplayName} replaced"
: $"default token for {plan.Place.DisplayName} stored";
case SettingsTokenAction.Remove:
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
settings.DefaultGiteaServer = null;
return _token.DependentCount == 0
? $"default token for {_savedPlace!.DisplayName} removed"
: $"default token for {_savedPlace!.DisplayName} removed ({_token.DependentCount} account(s) depend on it)";
case SettingsTokenAction.Keep when plan.TakesUp:
/////////////////////////////////////////////////////////////////////////
// Looked for again, now: the plan was made when the address was typed, //
// and the token may have been removed since (another window, Windows' //
// Credential Manager). Without it there is nothing to take up, and the //
// default that is saved is not given up for it. //
/////////////////////////////////////////////////////////////////////////
if(DefaultTokens.For(vault, plan.Place!) is null)
{
return $"default token for {plan.Place!.DisplayName} not taken up: it is no longer saved; nothing was changed";
}
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
if(!HasSavedToken)
{
return $"default token for {plan.Place.DisplayName} taken up again (it was already saved)";
}
// One default Gitea server: the one it takes the place of goes, as when a token is typed for another server.
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
return $"default token for {plan.Place.DisplayName} taken up again (it was already saved), and the one for {_savedPlace!.DisplayName} removed";
default:
return null;
}
}
private static SettingsGiteaPlan Nothing => new(SettingsTokenAction.Keep, null, "");
private static SettingsGiteaPlan Refused(string why) => new(SettingsTokenAction.Keep, null, why);
}
+22 -4
View File
@@ -36,6 +36,24 @@ public sealed class SettingsTokenState
/// <summary>
/// What was typed, without the mask. A token box that shows the mask and is
/// typed into without being emptied first holds the mask and then the typing;
/// stored like that, the token would begin with the mask's dots. The mask alone
/// stays the mask (it means "unchanged"); a box that holds none of it is as it is.
/// The box itself is left as it is (rewriting it while it is typed into moves the
/// caret): this is applied wherever its value is read, and to what is stored.
/// </summary>
public static string WithoutTheMask(string boxValue)
{
ArgumentNullException.ThrowIfNull(boxValue);
return string.Equals(boxValue, Mask, StringComparison.Ordinal) || !boxValue.Contains(Mask[0], StringComparison.Ordinal)
? boxValue
: boxValue.Replace(Mask[0].ToString(), "", StringComparison.Ordinal);
}
/// <summary>True when the vault held a default token when the dialog opened.</summary>
public bool HasSavedToken { get; }
@@ -53,7 +71,7 @@ public sealed class SettingsTokenState
/// <summary>What Save will do with <paramref name="boxValue"/>, the box's current content.</summary>
public SettingsTokenAction Plan(string boxValue)
{
ArgumentNullException.ThrowIfNull(boxValue);
boxValue = WithoutTheMask(boxValue);
if(string.Equals(boxValue, Mask, StringComparison.Ordinal))
{
return HasSavedToken ? SettingsTokenAction.Keep : SettingsTokenAction.Store;
@@ -93,7 +111,7 @@ public sealed class SettingsTokenState
private string UsedBy() => DependentCount switch
internal string UsedBy() => DependentCount switch
{
0 => " and used by Quick Sync",
1 => " and used by Quick Sync and 1 account",
@@ -102,7 +120,7 @@ public sealed class SettingsTokenState
private string RotationNote() => DependentCount switch
internal string RotationNote() => DependentCount switch
{
0 => "",
1 => " The 1 account that uses it switches to the new token too.",
@@ -111,7 +129,7 @@ public sealed class SettingsTokenState
private string RemovalWarning()
internal string RemovalWarning()
{
if(DependentCount == 0)
{
+2 -2
View File
@@ -93,7 +93,7 @@ public sealed class ShareInvitation
"Hi,",
"",
"I use gclo and thought you might like it. It is a small Windows app (with a command-line version) that "
+ "clones every repository of a GitHub organization or account into a folder and keeps them up to date.",
+ "clones every repository of an organization or account on GitHub or on a Gitea server into a folder and keeps them up to date.",
"",
"Download the latest release:",
repository + "/releases/latest",
@@ -110,6 +110,6 @@ public sealed class ShareInvitation
"(sent from gclo " + version.Trim() + ")",
];
return new ShareInvitation(
"Try gclo: clone and update every repository of a GitHub organization", string.Join("\r\n", lines));
"Try gclo: clone and update every repository of a GitHub or Gitea organization", string.Join("\r\n", lines));
}
}
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// Which workspace the app opens on. The start screen is the thing the user most
/// likely came to do: where they left off, and when nothing says where that was,
/// their first account; Quick Sync when there is no account.
/// </summary>
public static class StartWorkspace
{
/// <summary>The id that stands for Quick Sync, which has no account.</summary>
public static readonly Guid QuickSync = Guid.Empty;
/// <summary>
/// The workspace to open: <paramref name="last"/> when it is Quick Sync or an
/// account that still exists; otherwise the first of <paramref name="accounts"/>
/// (they come sorted by name); otherwise Quick Sync.
/// </summary>
/// <param name="accounts">The saved accounts, in the order the pane lists them.</param>
/// <param name="last">The workspace that was open last; null when nothing is recorded.</param>
public static Guid Choose(IReadOnlyList<Account> accounts, Guid? last)
{
ArgumentNullException.ThrowIfNull(accounts);
if(last is Guid id && (id == QuickSync || accounts.Any(a => a.Id == id)))
{
return id;
}
return accounts.Count > 0 ? accounts[0].Id : QuickSync;
}
}
+5 -5
View File
@@ -7,11 +7,11 @@ 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).
/// Where an account's token comes from. <see cref="Own"/> is the account's own
/// vault entry (<see cref="Account.TokenEntryId"/>); <see cref="Default"/> is the
/// default token saved in Settings for the place the account is on
/// (<see cref="DefaultTokens"/>), read at use time, so replacing that default token
/// in Settings rotates every such account at once (docs/plans/default-token-ux.md).
/// </summary>
public enum TokenSource
{
+149
View File
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace gclo.ViewModels;
/// <summary>
/// One gclo window per data folder (#216). Two windows on one folder each kept
/// their own copy of the settings and of the accounts, and the one that saved
/// last undid what the other had saved. A window therefore claims its data
/// folder while it is open; a second start finds the claim, asks the open window
/// to come to the front, and ends.
/// <para>
/// The claim is a file in the data folder (<c>window.lock</c>) held open and
/// shared with nobody: Windows gives it up when the process ends, however it
/// ends, and the file is removed with it. The request to come to the front is a
/// named event, named after the folder. Another data folder (the dev channel
/// beside the stable one, <c>GCLO_DATA_DIR</c>) is another claim.
/// </para>
/// <para>
/// The command line makes no claim: it opens no window and may run beside the app.
/// </para>
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaim : IDisposable
{
/// <summary>The file is open in another process (ERROR_SHARING_VIOLATION as an HRESULT).</summary>
private const int SharingViolation = unchecked((int)0x80070020);
private readonly FileStream? _held;
/// <summary>Null when the event could not be made or opened: nothing is asked then, and nothing heard.</summary>
private readonly EventWaitHandle? _show;
private RegisteredWaitHandle? _waiting;
private WindowClaim(FileStream? held, bool isTheOnlyOne, EventWaitHandle? show)
{
_held = held;
IsTheOnlyOne = isTheOnlyOne;
_show = show;
}
/// <summary>
/// Claims <paramref name="dataRoot"/> for a window of this process. When
/// another window holds it, <see cref="IsTheOnlyOne"/> is false. A folder that
/// cannot be written to holds no claim and stops no window from opening: what
/// cannot be saved there is reported where it is saved.
/// </summary>
public static WindowClaim For(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
FileStream? held = null;
bool anotherHoldsIt = false;
try
{
Directory.CreateDirectory(dataRoot);
held = new FileStream(
Path.Combine(dataRoot, "window.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None,
bufferSize: 1, FileOptions.DeleteOnClose);
}
catch(IOException ex) when(ex.HResult == SharingViolation)
{
anotherHoldsIt = true;
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
// No claim can be made here (a read-only profile, a path that is no folder): the window opens all the same.
}
EventWaitHandle? show = null;
try
{
show = new EventWaitHandle(initialState: false, EventResetMode.AutoReset, $@"Local\gclo-show-{KeyFor(dataRoot)}");
}
catch(Exception ex) when(ex is UnauthorizedAccessException or WaitHandleCannotBeOpenedException or IOException)
{
/////////////////////////////////////////////////////////////////////////////
// The name is taken by something this process may not use (a gclo that //
// runs elevated made it), or by something that is no event. The claim on //
// the folder stands as the file said; only the request to come to the //
// front is not made. This runs at every start: it must not end one. //
/////////////////////////////////////////////////////////////////////////////
}
return new WindowClaim(held, !anotherHoldsIt, show);
}
/// <summary>
/// What the request to come to the front is named after: the folder as Windows
/// knows it, whatever its spelling (case, a separator at the end, a relative path).
/// </summary>
public static string KeyFor(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
string folder = Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataRoot)).ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(folder)), 0, 16);
}
/// <summary>True when no other window holds the data folder: this process may open one.</summary>
public bool IsTheOnlyOne { get; }
/// <summary>Asks the window that holds the data folder to come to the front.</summary>
public void AskTheOtherToShowItself() => _show?.Set();
/// <summary>
/// What the window that holds the claim does when another start asks it to come
/// to the front. <paramref name="show"/> runs on a thread of the pool, once for
/// each request; setting it again replaces the one before.
/// </summary>
public void OnAskedToShow(Action show)
{
ArgumentNullException.ThrowIfNull(show);
_waiting?.Unregister(null);
_waiting = _show is null
? null
: ThreadPool.RegisterWaitForSingleObject(_show, (_, _) => show(), state: null, Timeout.Infinite, executeOnlyOnce: false);
}
/// <summary>Gives the claim up: the next start opens a window of its own.</summary>
public void Dispose()
{
_waiting?.Unregister(null);
_waiting = null;
_show?.Dispose();
_held?.Dispose();
}
}
+451 -29
View File
@@ -23,7 +23,8 @@ namespace gclo.ViewModels;
/// </summary>
public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
private readonly IRepositoryLister _lister;
/// <summary>The lister of the place this workspace is on. Quick Sync replaces it when another place is chosen.</summary>
private IRepositoryLister _lister;
private readonly IGitClient _git;
@@ -32,7 +33,36 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Result of the one-time WSL probe; null until the first path recovery asks for it.</summary>
private WslAvailability? _wslAvailability;
private readonly IOrganizationLister _orgLister;
/// <summary>The organization lister of the place this workspace is on; replaced together with <see cref="_lister"/>.</summary>
private IOrganizationLister _orgLister;
/// <summary>The listers of a place, for a Quick Sync whose place can be chosen; null when the place is fixed.</summary>
private readonly Func<ForgeEndpoint, (IRepositoryLister Repositories, IOrganizationLister Organizations)>? _listersOf;
/// <summary>The default token saved for a place, which Quick Sync fills in; null when there is no such source.</summary>
private readonly Func<ForgeEndpoint, string?>? _defaultTokenOf;
/// <summary>Quick Sync's place, as it was last taken from the choice on its card. The listers are the ones of this place.</summary>
private ForgeEndpoint _quickSyncPlace = ForgeEndpoint.GitHub;
/// <summary>
/// False while the choice on Quick Sync's card names no place that can be used:
/// nothing was chosen yet, or a Gitea server without an address, or with one
/// that is refused. Nothing is sent anywhere meanwhile.
/// </summary>
private bool _placeIsUsable = true;
/// <summary>
/// Whether a place was chosen. A Quick Sync whose place is chosen on its card
/// starts with none: no place is taken for granted, so no token can be sent to a
/// place nobody chose. An account's place, and that of a Quick Sync without the
/// choice, is given.
/// </summary>
private bool _placeChosen = true;
private bool _isOnGitea;
private string _server = "";
private readonly Func<Action<RepoProgress>, IProgress<RepoProgress>> _progressFactory;
@@ -113,6 +143,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// (<see cref="RefreshAccountToken"/>), so one that was replaced or removed since
/// (the default token in Settings, the account's own in the wizard) takes effect
/// without a restart.
/// <paramref name="listersOf"/> makes this a Quick Sync whose place is chosen on
/// its card (<see cref="CanChoosePlace"/>): it gives the listers of a place, and a
/// token is only ever handed to the listers of the place the card is on.
/// <paramref name="defaultTokenOf"/> gives the default token saved for a place,
/// which such a Quick Sync fills in when it is on that place.
/// </summary>
public WorkspaceViewModel(
IRepositoryLister? lister = null,
@@ -124,13 +159,24 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
Account? account = null,
ITokenVault? tokenVault = null,
AccountsStore? accountsStore = null,
IWslCloner? wsl = null)
IWslCloner? wsl = null,
Func<ForgeEndpoint, (IRepositoryLister Repositories, IOrganizationLister Organizations)>? listersOf = null,
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
{
// An account's place is its own and fixed. Quick Sync starts on GitHub, and takes the listers of whatever place is chosen.
_listersOf = account is null ? listersOf : null;
_defaultTokenOf = account is null ? defaultTokenOf : null;
_placeChosen = _listersOf is null;
_placeIsUsable = _placeChosen;
(IRepositoryLister Repositories, IOrganizationLister Organizations)? ofGitHub = _listersOf?.Invoke(ForgeEndpoint.GitHub);
// Without listers of its own, a workspace gets those of its account's forge: Quick Sync is GitHub.
_lister = lister ?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account));
_lister = lister ?? ofGitHub?.Repositories
?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account));
_git = git ?? new LibGit2GitClient();
_wsl = wsl ?? new WslCloner();
_orgLister = orgLister ?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account));
_orgLister = orgLister ?? ofGitHub?.Organizations
?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account));
_progressFactory = progressFactory ?? DefaultProgress;
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
_log = log ?? new FileActivityLog(GcloPaths.LogDirectory);
@@ -146,6 +192,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
ResultMessage = "";
LoadErrorTitle = "";
LoadErrorMessage = "";
ServerError = "";
AllSelected = true;
CanEditInputs = true;
@@ -174,27 +221,330 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
public string DisplayName => _account?.Name ?? "Quick Sync";
/// <summary>
/// How the place this workspace connects to is named: "GitHub" for Quick Sync
/// and a GitHub account, the server's host for an account on a Gitea server. For
/// an account whose stored place cannot be used, what <see cref="Account.DescribePlace"/> says.
/// How the place this workspace connects to is named: "GitHub", or the server's
/// host. For Quick Sync it follows the choice on its card, and is "Gitea" until an
/// address is there that can be used. For an account whose stored place cannot
/// be used, what <see cref="Account.DescribePlace"/> says.
/// </summary>
public string PlaceName => _account is null ? ForgeEndpoint.GitHub.DisplayName : _account.DescribePlace();
public string PlaceName
=> _account is not null ? _account.DescribePlace()
: !_placeChosen ? ""
: _placeIsUsable ? _quickSyncPlace.DisplayName
: nameof(ForgeKind.Gitea);
/// <summary>What the token of this workspace's forge is called: the header of its token box.</summary>
public string TokenHeader => ForgeTexts.TokenName(_account?.Forge ?? ForgeKind.GitHub);
/// <summary>The kind of forge this workspace is on; for Quick Sync, the one chosen on its card.</summary>
private ForgeKind PlaceKind => _account?.Forge ?? (_isOnGitea ? ForgeKind.Gitea : ForgeKind.GitHub);
/// <summary>The title of the connect card: where this workspace connects to.</summary>
public string ConnectTitle => $"Connect to {PlaceName}";
/// <summary>What the token of this workspace's forge is called: the header of its token box. Before a place is chosen, the plain name.</summary>
public string TokenHeader => _placeChosen ? ForgeTexts.TokenName(PlaceKind) : "Access token";
/// <summary>What Quick Sync's empty token box says: the shape of a GitHub token, and nothing for another forge or before a place is chosen.</summary>
public string TokenPlaceholder => _placeChosen && PlaceKind == ForgeKind.GitHub ? "ghp_…" : "";
/// <summary>
/// Whether a place was chosen. False for a Quick Sync that was just opened for
/// the first time: its card then asks first, and takes no token before.
/// </summary>
public bool IsPlaceChosen => _placeChosen;
/// <summary>
/// Whether this workspace's card asks where the repositories are: Quick Sync,
/// when it was given the listers of a place. An account's place is fixed.
/// </summary>
public bool CanChoosePlace => _listersOf is not null;
/// <summary>
/// The choice on Quick Sync's card: false is GitHub, true is a Gitea server whose
/// address is <see cref="Server"/>. Choosing the other kind empties what was
/// entered or loaded for this one: the token, the organization, the table. It
/// cannot be changed while the workspace is busy (<see cref="CanEditInputs"/>).
/// </summary>
public bool IsOnGitea
{
get => _isOnGitea;
set
{
if((value != _isOnGitea || !_placeChosen) && _listersOf is { } listersOf && CanEditInputs)
{
///////////////////////////////////////////////////////////////////////
// Another kind of forge, or the first choice at all: what is on the //
// card was entered for the other place, or for no place. Neither is //
// carried to the place that is chosen now. //
///////////////////////////////////////////////////////////////////////
_isOnGitea = value;
_placeChosen = true;
ForgetTheOtherPlace();
TakeThePlace(listersOf);
}
// Always said, also when nothing was taken: a control that asked for a change that was refused shows the choice as it is.
OnPropertyChanged();
OnPropertyChanged(nameof(PlaceIndex));
OnPropertyChanged(nameof(AsksForServer));
OnPropertyChanged(nameof(IsPlaceChosen));
}
}
/// <summary>
/// <see cref="IsOnGitea"/> as the selected index of a two-item radio group:
/// 0 = GitHub, 1 = Gitea, and -1 while nothing was chosen. Setting any other
/// index than 0 or 1 (a radio group reports -1 while it has no selection)
/// changes nothing.
/// </summary>
public int PlaceIndex
{
get => !_placeChosen ? -1 : _isOnGitea ? 1 : 0;
set
{
if(value is 0 or 1)
{
IsOnGitea = value == 1;
}
}
}
/// <summary>Whether the card shows the box for the server's address.</summary>
public bool AsksForServer => CanChoosePlace && _placeChosen && _isOnGitea;
/// <summary>
/// The address of the Gitea server on Quick Sync's card. It is taken when it is
/// set, so the shell sets it when the box is left, not while it is typed: no
/// token is sent to half an address. Once taken it reads as it was normalized,
/// which is the address a token goes to.
/// </summary>
public string Server
{
get => _server;
set
{
string typed = value ?? "";
if((!string.Equals(typed, _server, StringComparison.Ordinal) || !_placeIsUsable) && _listersOf is { } listersOf && CanEditInputs)
{
_server = typed;
TakeThePlace(listersOf);
}
OnPropertyChanged();
}
}
/// <summary>
/// Told by the shell that the address box is being typed into and holds another
/// text than <see cref="Server"/>. Until the address is set again (the box is
/// left, or Enter is pressed), the card names no place that can be used: nothing
/// can be loaded, looked up or synced with the address that is no longer the one
/// on screen, whatever starts it (a button, a keyboard shortcut).
/// </summary>
public void ServerIsBeingEdited()
{
if(AsksForServer && _placeIsUsable)
{
_placeIsUsable = false;
SayWhatThePlaceAllows();
}
}
/// <summary>
/// Says what follows from the place the card names: its texts, and what can be
/// done with it. While it names no usable place, a lookup that is on its way is
/// ended and the connection is not offered as an account.
/// </summary>
private void SayWhatThePlaceAllows()
{
if(!_placeIsUsable)
{
_orgLoadCts?.Cancel();
SaveAsAccountOffered = false;
}
OnPropertyChanged(nameof(PlaceName));
OnPropertyChanged(nameof(ConnectTitle));
OnPropertyChanged(nameof(TokenHeader));
OnPropertyChanged(nameof(TokenPlaceholder));
OnPropertyChanged(nameof(TokenPage));
LoadReposCommand.NotifyCanExecuteChanged();
FindOrganizationsCommand.NotifyCanExecuteChanged();
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
ResolvePathsCommand.NotifyCanExecuteChanged();
}
/// <summary>What is wrong with <see cref="Server"/>; empty when it can be used, and while none was entered yet.</summary>
[ObservableProperty]
public partial string ServerError { get; set; }
/// <summary>
/// True once Quick Sync loaded repositories: the connection works, and the page
/// offers to save it as an account (where its token is kept). Set back when the
/// offer is dismissed, and when another place is chosen.
/// </summary>
[ObservableProperty]
public partial bool SaveAsAccountOffered { get; set; }
/// <summary>
/// Told about a Quick Sync connection that just loaded its repositories, so the
/// shell can remember it for the next start. Never told a token.
/// </summary>
public Action<QuickSyncConnection>? Connected { get; set; }
/// <summary>The title of the connect card: where this workspace connects to; just "Connect" before a place is chosen.</summary>
public string ConnectTitle => _placeChosen ? $"Connect to {PlaceName}" : "Connect";
/// <summary>The page a token for this workspace is made on; null when the account's place is not known.</summary>
public Uri? TokenPage => ForgeTexts.TokenPage(Place);
/// <summary>The place this workspace connects to; null for an account whose stored forge and server cannot be used.</summary>
/// <summary>
/// The place this workspace connects to. Null for an account whose stored forge
/// and server cannot be used, and for Quick Sync while its card names no usable place.
/// </summary>
private ForgeEndpoint? Place
=> _account is null ? ForgeEndpoint.GitHub
=> _account is null ? (_placeIsUsable ? _quickSyncPlace : null)
: _account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place
: null;
/// <summary>
/// Takes the place the card names, after the choice or the address changed. A
/// place that can be used and is another one than before brings its own listers,
/// and what belonged to the other place is forgotten first: the token typed for
/// it is not handed to the new listers, and the repositories loaded from it (whose
/// clone addresses are on its host) do not stay in the table to be synced with a
/// token of the new place.
/// </summary>
private void TakeThePlace(Func<ForgeEndpoint, (IRepositoryLister Repositories, IOrganizationLister Organizations)> listersOf)
{
ForgeEndpoint? place = null;
string error = "";
if(!_isOnGitea)
{
place = ForgeEndpoint.GitHub;
}
else if(!string.IsNullOrWhiteSpace(_server) && !ForgeEndpoint.TryCreate(ForgeKind.Gitea, _server, out place, out string? reason))
{
error = char.ToUpperInvariant(reason[0]) + reason[1..] + ".";
}
ServerError = error;
///////////////////////////////////////////////////////////////////////////////
// Until the place is taken below, the card names no place that can be used: //
// if the listers of the new place cannot be made, nothing is sent to the //
// listers of the old one while the card already shows the new address. //
///////////////////////////////////////////////////////////////////////////////
_placeIsUsable = false;
if(place is not null && !place.Equals(_quickSyncPlace))
{
(IRepositoryLister repositories, IOrganizationLister organizations) = listersOf(place);
ForgetTheOtherPlace();
_lister = repositories;
_orgLister = organizations;
_quickSyncPlace = place;
}
_placeIsUsable = _placeChosen && place is not null;
if(place?.Server is not null)
{
_server = place.Server.AbsoluteUri;
OnPropertyChanged(nameof(Server));
}
SayWhatThePlaceAllows();
FillInTheDefaultToken();
}
/// <summary>
/// Empties what was entered or loaded for the place Quick Sync was on: the token
/// (which also ends a lookup that is on its way and empties the organization
/// list), the organization, the table, and the offer to save the connection.
/// </summary>
private void ForgetTheOtherPlace()
{
Token = "";
Organization = "";
SaveAsAccountOffered = false;
foreach(RepoItemViewModel existing in Repos)
{
existing.PropertyChanged -= OnItemPropertyChanged;
}
Repos.Clear();
_itemsByName.Clear();
TotalCount = 0;
CompletedCount = 0;
_runSet = [];
SortColumn = null;
SortDescending = false;
_sampleRepoName = null;
RebuildReposIndex();
LoadedOrganization = null;
HasLoadedRepos = false;
StatusText = "";
ResetRunResult();
LoadErrorOpen = false;
NotifyTargetPathChanged();
RecomputeSelectedCount();
RebuildFilteredRepos();
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
RecomputeHasFailedRepos();
}
/// <summary>
/// Quick Sync, on a place that can be used and with an empty token box: fills in
/// the default token saved for that place, if there is one. Nothing otherwise.
/// The token of one place is never filled in for another: the source is asked
/// for the place the card is on.
/// </summary>
public void FillInTheDefaultToken()
{
if(_account is null && _placeIsUsable && Token.Length == 0 && _defaultTokenOf?.Invoke(_quickSyncPlace) is { Length: > 0 } saved)
{
Token = saved;
}
}
/// <summary>
/// Puts Quick Sync back the way a connection that worked had it: the place, the
/// organization, the target folder. No token: one is filled in only from a saved
/// default (<see cref="FillInTheDefaultToken"/>), and nothing is loaded until
/// the user asks. Nothing happens for an account, or for a Quick Sync whose
/// place cannot be chosen.
/// </summary>
public void Restore(QuickSyncConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
if(_listersOf is null)
{
return;
}
IsOnGitea = connection.Place.Server is not null;
Server = connection.Place.Server?.AbsoluteUri ?? "";
Organization = connection.Organization;
if(connection.TargetFolder.Length > 0)
{
TargetFolder = connection.TargetFolder;
CreateOrgSubfolder = connection.CreateOrgSubfolder;
}
}
/// <summary>
/// What the account wizard needs to save this Quick Sync connection as an
/// account, the place included: the token was used with that place. Null while
/// the card names no place that can be used: there is no connection to save.
/// </summary>
public AccountWizardSeed? ToAccountSeed()
=> _placeIsUsable ? new AccountWizardSeed(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace) : null;
/// <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
@@ -410,7 +760,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>
/// True once this workspace has completed its first successful repository load;
/// drives the connect-card vs workspace visibility switch. Never reset.
/// drives the connect-card vs workspace visibility switch. Set back only when
/// Quick Sync is put on another place, which empties the table.
/// </summary>
[ObservableProperty]
public partial bool HasLoadedRepos { get; set; }
@@ -564,11 +915,27 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
? $"Token cleared in workspace '{DisplayName}'."
: $"Token entered in workspace '{DisplayName}' ({value.Length} characters).");
}
_ = RefreshOrganizationsAsync();
FindOrganizationsCommand.NotifyCanExecuteChanged();
_ = RefreshOrganizationsAsync(asked: false);
}
private bool CanFindOrganizations() => _placeIsUsable && Token.Trim().Length >= 10;
/// <summary>
/// Lists the organizations the token can see, because the user asked. On a Quick
/// Sync whose place is chosen on its card this is the only way the list is
/// fetched: a token that was typed, pasted or filled in is sent nowhere until
/// the user presses this (or Load repositories).
/// </summary>
[RelayCommand(CanExecute = nameof(CanFindOrganizations))]
private Task FindOrganizationsAsync() => RefreshOrganizationsAsync(asked: true);
/// <summary>
/// Takes an account workspace's token from its source again (its own vault entry,
/// or the default token from Settings) and uses it from here on. Called before the
@@ -657,16 +1024,19 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// Sync follows only when its box still holds what Settings put there
/// (<paramref name="previous"/>, or nothing): a token somebody typed stays.
/// </summary>
/// <param name="place">The place whose default token changed.</param>
/// <param name="previous">The default token before the change; null when there was none.</param>
/// <param name="current">The default token now; null when there is none.</param>
public void OnDefaultTokenChanged(string? previous, string? current)
public void OnDefaultTokenChanged(ForgeEndpoint place, string? previous, string? current)
{
ArgumentNullException.ThrowIfNull(place);
if(_account is not null)
{
RefreshAccountToken();
}
else if(string.Equals(Token, previous ?? "", StringComparison.Ordinal))
else if(_placeIsUsable && _quickSyncPlace.Equals(place) && string.Equals(Token, previous ?? "", StringComparison.Ordinal))
{
// The default token of one place: a Quick Sync that is on another place is not given it.
SetTokenFromItsSource(current ?? "");
}
}
@@ -760,8 +1130,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Debounced: each token edit cancels the previous lookup.</summary>
private async Task RefreshOrganizationsAsync()
/// <summary>
/// Looks the organizations of the token up; each token edit cancels the previous
/// lookup. <paramref name="asked"/> says the user asked for it. Without being
/// asked it runs (after a pause) for an account, whose token was saved for its
/// place, and never for a Quick Sync whose place is chosen on its card: there a
/// token leaves the machine only on the user's word.
/// </summary>
private async Task RefreshOrganizationsAsync(bool asked)
{
_orgLoadCts?.Cancel();
_orgLoadCts?.Dispose();
@@ -777,8 +1153,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
///////////////////////////////////////////////////////////////////////////////
bool quiet = _readingTokenFromItsSource;
///////////////////////////////////////////////////////////////////////////////
// The lister is taken with the token, here: the token of this moment goes //
// to the lister of this moment, whatever Quick Sync's place is by the time //
// the pause below is over. //
///////////////////////////////////////////////////////////////////////////////
IOrganizationLister lister = _orgLister;
string token = Token.Trim();
if(token.Length < 10)
if(token.Length < 10 || !_placeIsUsable || (_listersOf is not null && !asked))
{
// The same capture and restore as below: the editable box loses its text
// when its list changes, and an account's organization must survive its
@@ -795,10 +1177,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
try
{
await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes
if(!asked)
{
await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes
}
lookupToken.ThrowIfCancellationRequested(); // superseded just as the pause ended: nothing is sent
IsLoadingOrgs = true;
_log.Info("Organization lookup started.");
IReadOnlyList<string> orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken);
IReadOnlyList<string> orgs = await lister.ListOrganizationsAsync(token, lookupToken);
lookupToken.ThrowIfCancellationRequested();
_log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible.");
@@ -820,9 +1206,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
LoadErrorOpen = false; // a lookup that works clears a stale lookup failure
// The production lister always lists the token's own account first, so a
// single entry means no organizations were visible.
StatusText = orgs.Count <= 1
? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually."
: $"Found {orgs.Count} organizations and accounts.";
// What a token that sees no organization lacks is called differently on each forge.
StatusText = orgs.Count > 1 ? $"Found {orgs.Count} organizations and accounts."
: PlaceKind == ForgeKind.GitHub
? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually."
: "Only your personal account is visible — allow the token read:organization for organizations, or type an org name manually.";
}
}
catch(OperationCanceledException)
@@ -964,6 +1352,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& !string.IsNullOrWhiteSpace(Organization)
&& !string.IsNullOrWhiteSpace(Token);
@@ -973,6 +1362,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[RelayCommand(CanExecute = nameof(CanLoadRepos))]
private async Task LoadReposAsync()
{
if(!_placeIsUsable)
{
// Whatever started it: there is no place to ask. No token is sent to a place nobody chose.
return;
}
bool firstLoad = !HasLoadedRepos;
IsLoadingRepos = true;
ResetRunResult();
LoadErrorOpen = false;
@@ -1016,6 +1412,24 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
HasLoadedRepos = true;
StatusText = Repos.Count == 1 ? "1 repository loaded." : $"{Repos.Count} repositories loaded.";
_log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'.");
if(_account is null)
{
///////////////////////////////////////////////////////////////////////
// The connection works: worth keeping as an account, said once (a //
// refresh does not bring a dismissed offer back), and worth opening //
// on next time. Remembering it is not part of loading: it failing //
// does not make the load a failure. //
///////////////////////////////////////////////////////////////////////
SaveAsAccountOffered = SaveAsAccountOffered || firstLoad;
try
{
Connected?.Invoke(new QuickSyncConnection(_quickSyncPlace, organization, TargetFolder.Trim(), CreateOrgSubfolder));
}
catch(Exception ex)
{
_log.Error($"The connection could not be remembered for the next start: {ex.Message}", ex);
}
}
}
catch(Exception ex)
{
@@ -1045,6 +1459,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& !IsTableStale
&& !string.IsNullOrWhiteSpace(TargetFolder)
&& Repos.Any(r => r.IsSelected);
@@ -1055,6 +1470,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)]
private async Task SyncAsync(CancellationToken cancellationToken)
{
if(!_placeIsUsable)
{
// Whatever started it: the table is of a place the card no longer names.
return;
}
IsRunning = true;
ResetRunResult();
try
@@ -1172,6 +1593,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& Repos.Any(r => r.Status == SyncStatus.Failed);
@@ -1251,7 +1673,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private bool CanResolvePaths(RepoItemViewModel item)
=> item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths;
=> item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths && _placeIsUsable;
@@ -1262,7 +1684,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[RelayCommand(CanExecute = nameof(CanResolvePaths))]
private async Task ResolvePathsAsync(RepoItemViewModel item)
{
if(item is null || RecoveryInteraction is not { } interaction)
if(item is null || !_placeIsUsable || RecoveryInteraction is not { } interaction)
{
return;
}
+12 -2
View File
@@ -79,7 +79,17 @@ public partial class App : Application
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
_window = new MainWindow();
_window.Activate();
try
{
_window = new MainWindow();
_window.Activate();
}
catch
{
// No window came of it, and this process may live on (the handler above keeps it alive): it must not
// hold the data folder, or every later start would end at once with nothing to bring to the front (#216).
Program.Claim?.Dispose();
throw;
}
}
}
+72 -6
View File
@@ -8,19 +8,73 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
IsTabStop="False"
Loaded="OnLoaded">
Loaded="OnLoaded"
Unloaded="OnUnloaded">
<!-- The one connection form: token, organization, target folder, subfolder
<!-- The one connection form: for Quick Sync the place (GitHub, or Gitea and the
server's address), then token, organization, target folder, subfolder
preference, and the path preview. Hosted by the connect card and by the
chip's Edit flyout (WorkspacePage), both over the same view model, so a
change here lands in both places and the FlaUI suite can target either
through its AutomationIdPrefix. -->
<StackPanel Spacing="12">
<!-- Where the repositories are (#209). First on the form: the token below is
for this place. Asked on Quick Sync only; an account's place is fixed,
and its page says where it is in its title. -->
<!-- Two radio buttons of their own, not a RadioButtons group: that control
selects its first option as soon as keyboard focus lands on it, which
happens when the window opens. The place has to be chosen by an act
(a click, Space, an arrow key), never by focus: a token typed next goes
to the place that is chosen. -->
<StackPanel
Spacing="4"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.CanChoosePlace), Mode=OneWay}">
<TextBlock x:Name="PlaceQuestion" Text="Where are the repositories?" />
<StackPanel
AutomationProperties.AutomationId="{x:Bind Id('PlaceChoice')}"
AutomationProperties.LabeledBy="{x:Bind PlaceQuestion}"
Orientation="Horizontal"
Spacing="24">
<RadioButton
x:Name="PlaceGitHub"
AutomationProperties.AutomationId="{x:Bind Id('PlaceGitHub')}"
MinWidth="0"
Content="GitHub"
Checked="Place_Checked" />
<RadioButton
x:Name="PlaceGitea"
AutomationProperties.AutomationId="{x:Bind Id('PlaceGitea')}"
MinWidth="0"
Content="Gitea"
Checked="Place_Checked" />
</StackPanel>
</StackPanel>
<!-- Taken when the box is left (or Enter is pressed), never while it is
typed: no token is sent to half an address. -->
<TextBox
x:Name="ServerBox"
AutomationProperties.AutomationId="{x:Bind Id('ServerBox')}"
Header="Server address"
ToolTipService.ToolTip="The address of the Gitea (or Forgejo) server itself. HTTPS only; the token is sent to this address and nowhere else."
PlaceholderText="https://git.example.com"
Text="{x:Bind ViewModel.Server, Mode=OneWay}"
TextChanged="ServerBox_TextChanged"
LostFocus="ServerBox_LostFocus"
KeyDown="ServerBox_KeyDown"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.AsksForServer), Mode=OneWay}" />
<TextBlock
x:Name="ServerErrorText"
AutomationProperties.AutomationId="{x:Bind Id('ServerError')}"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.ServerError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.ServerError), Mode=OneWay}" />
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="{x:Bind Id('TokenBox')}"
Header="Personal access token"
ToolTipService.ToolTip="Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
ToolTipService.ToolTip="Needs read access to repositories. Never written to disk or logs."
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
@@ -32,6 +86,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
@@ -39,14 +94,25 @@
HorizontalAlignment="Stretch"
Header="Organization or account"
x:Name="OrgBox"
ToolTipService.ToolTip="The organization or user account whose repositories are synced. Populated from the token; typing a name also works."
ToolTipService.ToolTip="The organization or user account whose repositories are synced. Type its name, or list the ones the token can see."
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
PlaceholderText="Type a name, or press Find to list them"
Loaded="OrgBox_Loaded"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
<!-- Quick Sync sends a token nowhere by itself (#209): the list of
organizations is fetched when this is pressed. An account's page
lists them when it opens; its token was saved for its place. -->
<Button
Grid.Column="1"
AutomationProperties.AutomationId="{x:Bind Id('FindOrgsButton')}"
VerticalAlignment="Bottom"
Content="Find"
ToolTipService.ToolTip="Lists the organizations the token can see. Pressing this sends the token to the place chosen above; nothing is sent before."
Command="{x:Bind ViewModel.FindOrganizationsCommand, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.CanChoosePlace), Mode=OneWay}" />
<ProgressRing
Grid.Column="2"
Width="20"
Height="20"
Margin="0,0,0,8"
+252 -12
View File
@@ -97,18 +97,30 @@ public sealed partial class ConnectionFormControl : UserControl
///////////////////////////////////////////////////////////////////////////////
TokenBox.IsEnabled = !savedWithAnAccount;
// The page of the place this workspace connects to (#206); none when that place is not known.
if(ViewModel is not null)
ApplyThePlace();
if(PlaceGitHub.GroupName.Length == 0)
{
TokenBox.Header = ViewModel.TokenHeader;
TokenPageLink.NavigateUri = ViewModel.TokenPage;
TokenPageLink.Visibility = StatusFormat.VisibleIfNotNull(ViewModel.TokenPage);
// A group of this form's own: the connect card and the Edit flyout each host one.
PlaceGitHub.GroupName = PlaceGitea.GroupName = "gclo-place-" + Guid.NewGuid().ToString("N");
}
ShowThePlace();
if(ViewModel is not null && !ReferenceEquals(_followed, ViewModel))
{
///////////////////////////////////////////////////////////////////////////
// Quick Sync's place is chosen on this form (#209). What the form says //
// about the token follows it, and so does the token box: the view model //
// empties the token when another place is chosen, and fills in a saved //
// default; the box is mirrored by hand, so it is brought along here. //
// Followed only while the form is in the tree (see OnUnloaded): a form //
// that is not shown must not answer for one that is. //
///////////////////////////////////////////////////////////////////////////
StopFollowing();
_followed = ViewModel;
_followed.PropertyChanged += OnViewModelChanged;
}
TokenBox.PlaceholderText = !savedWithAnAccount ? "ghp_…"
: ViewModel!.UsesDefaultToken ? "No default token is saved"
: "No token is saved for this account";
// The organization placeholder must not ask for a token the box already has, or cannot take.
OrgBox.PlaceholderText = hasToken ? "Choose an organization, or type one"
OrgBox.PlaceholderText = ViewModel?.CanChoosePlace == true ? "Type a name, or press Find to list them"
: hasToken ? "Choose an organization, or type one"
: savedWithAnAccount ? "Type an organization or account"
: "Paste a token first, or type an org";
ToolTipService.SetToolTip(TokenBox, savedWithAnAccount
@@ -116,26 +128,254 @@ public sealed partial class ConnectionFormControl : UserControl
? "This account uses the default token from File > Settings."
: "This account's token is set with Edit account…, not typed here."
: !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."
? "Needs read access to repositories. Sent only when you press Find or Load repositories, and only to the place chosen above. Never written to disk or logs."
: "Using the saved default token from Settings. Type here to use a different one for this session.");
}
/// <summary>The view model whose place and token this control already follows.</summary>
private WorkspaceViewModel? _followed;
/// <summary>True while <see cref="FollowTheToken"/> changes the token box itself: that is not somebody typing.</summary>
private bool _followingTheToken;
private void OnUnloaded(object sender, RoutedEventArgs e) => StopFollowing();
private void StopFollowing()
{
if(_followed is not null)
{
_followed.PropertyChanged -= OnViewModelChanged;
_followed = null;
}
}
private void OnViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
switch(args.PropertyName)
{
case nameof(WorkspaceViewModel.Token):
FollowTheToken();
break;
case nameof(WorkspaceViewModel.PlaceIndex):
ShowThePlace();
break;
case nameof(WorkspaceViewModel.TokenHeader) or nameof(WorkspaceViewModel.TokenPage) or nameof(WorkspaceViewModel.TokenPlaceholder)
or nameof(WorkspaceViewModel.IsPlaceChosen):
ApplyThePlace();
break;
}
}
/// <summary>True while <see cref="ShowThePlace"/> checks the radio buttons itself: that is not the user choosing.</summary>
private bool _showingThePlace;
/// <summary>
/// Checks the radio button of the place the view model is on, and neither while
/// no place was chosen.
/// </summary>
private void ShowThePlace()
{
if(ViewModel is null)
{
return;
}
_showingThePlace = true;
try
{
int index = ViewModel.PlaceIndex;
PlaceGitHub.IsChecked = index == 0;
PlaceGitea.IsChecked = index == 1;
}
finally
{
_showingThePlace = false;
}
}
/// <summary>
/// A radio button was checked by the user (click, Space, an arrow key, or UI
/// automation): that is the choice of the place. Focus alone checks nothing. If
/// the view model does not take the choice (the workspace is busy), the buttons
/// go back to what it is on.
/// </summary>
private void Place_Checked(object sender, RoutedEventArgs e)
{
if(_showingThePlace || ViewModel is null)
{
return;
}
ViewModel.PlaceIndex = ReferenceEquals(sender, PlaceGitea) ? 1 : 0;
ShowThePlace();
}
/// <summary>
/// What the form says about the token of the place the workspace is on: the
/// header of the box, what the empty box shows, and the page a token is made on
/// (none when the place is not known).
/// </summary>
private void ApplyThePlace()
{
if(ViewModel is null)
{
return;
}
// No token is taken before the user said where the repositories are; an account's box takes none at all (#182).
TokenBox.IsEnabled = ViewModel.AccountId is null && ViewModel.IsPlaceChosen;
TokenBox.Header = ViewModel.TokenHeader;
TokenPageLink.NavigateUri = ViewModel.TokenPage;
TokenPageLink.Visibility = StatusFormat.VisibleIfNotNull(ViewModel.TokenPage);
TokenBox.PlaceholderText = ViewModel.AccountId is null ? ViewModel.TokenPlaceholder
: ViewModel.UsesDefaultToken ? "No default token is saved"
: "No token is saved for this account";
}
/// <summary>
/// Brings the token box along when the view model changed the token itself:
/// emptied (another place was chosen), or filled in from a saved default, which
/// shows as the mask. What was typed into the box is the view model's token
/// already, and stays.
/// </summary>
private void FollowTheToken()
{
if(_takingWhatWasTyped)
{
// The box is being typed into right now: it is not changed under the hand that types.
return;
}
string token = ViewModel.Token;
string shown = TokenBox.Password;
_followingTheToken = true;
try
{
if(token.Length == 0 && shown.Length > 0)
{
TokenBox.Password = "";
}
else if(token.Length > 0
&& !string.Equals(shown, token, StringComparison.Ordinal)
&& !string.Equals(shown, SettingsTokenState.Mask, StringComparison.Ordinal))
{
TokenBox.Password = SettingsTokenState.Mask;
}
}
finally
{
_followingTheToken = false;
}
}
/// <summary>
/// Hands the server address to the view model, which takes the place it names.
/// Called when the box is left or Enter is pressed, and before a token is taken
/// from the token box: a token is only ever sent to an address that was finished.
/// </summary>
private void CommitTheServer()
{
if(ViewModel is not null && ViewModel.AsksForServer)
{
ViewModel.Server = ServerBox.Text;
}
}
/// <summary>
/// The address box holds another text than the view model's address: it is being
/// typed into. The view model is told, so that nothing uses the address that is
/// no longer the one on screen until the box is left (a keyboard shortcut moves
/// no focus).
/// </summary>
private void ServerBox_TextChanged(object sender, TextChangedEventArgs e)
{
if(ViewModel is null)
{
return;
}
if(!string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
{
ViewModel.ServerIsBeingEdited();
}
else if(ViewModel.AsksForServer)
{
// The text is the address that was taken again (typed back): it is the place once more, without leaving the box.
ViewModel.Server = ServerBox.Text;
}
}
private void ServerBox_LostFocus(object sender, RoutedEventArgs e) => CommitTheServer();
private void ServerBox_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
{
if(e.Key == Windows.System.VirtualKey.Enter)
{
CommitTheServer();
}
}
// PasswordBox does not support reliable two-way x:Bind on Password; mirror it
// into the view model by hand. The mask is not a token: while the box shows it,
// the view model keeps the token it already holds.
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
{
string typed = ((PasswordBox)sender).Password;
if(ViewModel is not null && ViewModel.AccountId is null && !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal))
if(ViewModel is not null && ViewModel.AccountId is null && !_takingWhatWasTyped && !_followingTheToken
&& !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal))
{
ViewModel.Token = typed;
///////////////////////////////////////////////////////////////////////////
// The address first, in case its box was not left the usual way: the //
// token is for the place that is on the form. Taking the address can //
// empty the view model's token (another place); the box keeps what is //
// being typed, which is then the token of the place just taken. //
///////////////////////////////////////////////////////////////////////////
_takingWhatWasTyped = true;
try
{
CommitTheServer();
ViewModel.Token = typed;
}
finally
{
_takingWhatWasTyped = false;
}
}
}
/// <summary>True while <see cref="TokenBox_PasswordChanged"/> hands what was typed to the view model.</summary>
private bool _takingWhatWasTyped;
// An editable ComboBox does not render Text that was set before its template
// loaded, so an account workspace's seeded org would show as blank even though
// the view model holds it — see EditableComboBox.
+4 -1
View File
@@ -69,7 +69,10 @@
x:Name="WorkspaceNav"
AutomationProperties.AutomationId="WorkspaceNav"
Grid.Row="2"
PaneDisplayMode="Left"
PaneDisplayMode="Auto"
ExpandedModeThresholdWidth="0"
CompactModeThresholdWidth="0"
PaneTitle="Workspaces"
IsBackButtonVisible="Collapsed"
IsSettingsVisible="False"
OpenPaneLength="240"
+99 -20
View File
@@ -52,6 +52,9 @@ public sealed partial class MainWindow : Window
/// <summary>Set once the root's theme-change subscription exists.</summary>
private bool _themeHooked;
/// <summary>Set when the window has closed: a request to come to the front that arrives after that is dropped (#216).</summary>
private bool _closed;
private readonly AppSettings _settings;
private readonly UpdateService _updateService;
@@ -161,11 +164,18 @@ public sealed partial class MainWindow : Window
_settings = AppSettings.Load(_log);
// Create and show Quick Sync before selecting it, so NavigationView.Content
// Create and show Quick Sync before selecting anything, so NavigationView.Content
// is never empty regardless of when SelectionChanged fires.
ShowWorkspace(Guid.Empty);
ApplySettings(seedQuickSyncConcurrency: true);
WorkspaceNav.SelectedItem = QuickSyncNavItem;
// The start screen is where the user left off; with nothing recorded, their
// first account, and Quick Sync when there is none (#209).
Guid? workspaceAtStart = _settings.LastWorkspace;
Guid start = StartWorkspace.Choose(_accountsStore.GetAll(), workspaceAtStart);
WorkspaceNav.SelectedItem = WorkspaceNav.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => item.Tag is Guid id && id == start) ?? QuickSyncNavItem;
// AppWindow.Resize takes physical pixels; scale by the monitor DPI so the
// window is the same visual size at 150%/200% display scaling. The default
@@ -196,8 +206,18 @@ public sealed partial class MainWindow : Window
presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale);
}
// Another start of gclo on this data folder ends at once and asks this window to come to the front (#216).
Program.Claim?.OnAskedToShow(() => queue.TryEnqueue(ComeToTheFront));
Closed += (_, _) =>
{
// First: from here on a new start opens a window of its own, and does not ask one that is closing.
// A request that was already on its way finds the window closed and does nothing.
_closed = true;
Program.Claim?.Dispose();
// The workspace that is open now is where the next start opens: written here, whatever was written before.
_settings.SaveWhatWasRemembered(_log);
responsiveness.Dispose();
_logWindow?.Close(); // a log-only process would linger otherwise
DisposeWorkspaces();
@@ -399,6 +419,8 @@ public sealed partial class MainWindow : Window
_currentWorkspaceId = id;
RenderSyncAllBar(); // the link to the syncing account depends on what is on screen
_log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'.");
// Where the next start opens (#209); written when the window closes.
_settings.LastWorkspace = id;
}
}
}
@@ -542,16 +564,28 @@ public sealed partial class MainWindow : Window
{
if(id == Guid.Empty)
{
// Quick Sync: no account behind it. A saved default token (Settings)
// pre-fills the connect card; setting Token also starts the org lookup.
// Quick Sync: no account behind it. Its place is chosen on its card, and it
// gets the listers of that place (#209). It opens the way the last connection
// that worked had it, and with no place chosen when there was none. A default
// token saved for the place pre-fills the card. No token is remembered, and
// none is sent anywhere until the user presses Find or Load repositories.
var quickSync = new WorkspaceViewModel(
UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()),
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()),
log: _log);
if(_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken)
git: UiTestFixture.WrapGitClient(new LibGit2GitClient()),
log: _log,
listersOf: place => (
UiTestFixture.WrapRepositoryLister(place.CreateRepositoryLister(), place),
UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place)),
defaultTokenOf: place => DefaultTokens.For(_tokenVault, place))
{
quickSync.Token = defaultToken;
Connected = connection =>
{
_settings.RememberQuickSync(connection);
_settings.SaveWhatWasRemembered(_log);
},
};
if(_settings.RememberedQuickSync() is { } remembered)
{
quickSync.Restore(remembered);
}
return quickSync;
}
@@ -784,7 +818,6 @@ public sealed partial class MainWindow : Window
/// </summary>
private async Task ShowAccountWizardAsync(Account? existing)
{
Func<string?> defaultToken = () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
AccountWizardViewModel viewModel;
if(existing is null)
{
@@ -795,7 +828,7 @@ public sealed partial class MainWindow : Window
place => UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place),
_settings,
_log,
defaultToken);
place => DefaultTokens.For(_tokenVault, place));
}
else
{
@@ -809,7 +842,7 @@ public sealed partial class MainWindow : Window
existing,
() => _tokenVault.TryRetrieve(existing.TokenEntryId),
_log,
defaultToken);
defaultTokenOf: place => DefaultTokens.For(_tokenVault, place));
}
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
@@ -859,10 +892,11 @@ public sealed partial class MainWindow : Window
/// </summary>
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
{
// A Quick Sync connection is a GitHub connection, and so is the account it is saved as.
// The account is saved where the connection was: its token is checked with that place.
ForgeEndpoint place = seed.Place ?? ForgeEndpoint.GitHub;
var viewModel = new AccountWizardViewModel(
_accountsStore, UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), seed, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
_accountsStore, UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place), seed, _log,
defaultTokenOf: where => DefaultTokens.For(_tokenVault, where));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
@@ -877,6 +911,10 @@ public sealed partial class MainWindow : Window
await DialogGuard.ShowAsync(dialog);
if(dialog.Saved)
{
if(_workspaces.TryGetValue(Guid.Empty, out WorkspaceEntry? quickSync))
{
quickSync.ViewModel.SaveAsAccountOffered = false; // it was taken up
}
OnAccountAdded(viewModel);
}
else
@@ -1271,13 +1309,28 @@ public sealed partial class MainWindow : Window
if(await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
// Held only to tell the open workspaces what changed; never logged or stored.
string? defaultTokenBefore = _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
// A default token belongs to one place: GitHub's, and the one of the Gitea
// server that is named in Settings before the save and after it.
var places = new List<ForgeEndpoint> { ForgeEndpoint.GitHub };
if(_settings.DefaultGiteaPlace() is { } giteaBefore)
{
places.Add(giteaBefore);
}
var before = places.ToDictionary(place => place, place => DefaultTokens.For(_tokenVault, place));
dialog.ApplyAndSave(); // logs what changed
ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore);
string? defaultTokenNow = _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
foreach(WorkspaceEntry workspace in _workspaces.Values)
if(_settings.DefaultGiteaPlace() is { } giteaNow && !places.Contains(giteaNow))
{
workspace.ViewModel.OnDefaultTokenChanged(defaultTokenBefore, defaultTokenNow);
places.Add(giteaNow);
}
foreach(ForgeEndpoint place in places)
{
string? was = before.GetValueOrDefault(place);
string? now = DefaultTokens.For(_tokenVault, place);
foreach(WorkspaceEntry workspace in _workspaces.Values)
{
workspace.ViewModel.OnDefaultTokenChanged(place, was, now);
}
}
}
else
@@ -1604,6 +1657,32 @@ public sealed partial class MainWindow : Window
/// <summary>
/// Brings this window to the front because gclo was started again (#216): out of
/// the taskbar if it was minimized, and before the other windows.
/// </summary>
private void ComeToTheFront()
{
if(_closed)
{
return;
}
if(AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Minimized } presenter)
{
presenter.Restore();
}
Activate();
_ = SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this));
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hwnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int SetForegroundWindow(IntPtr hwnd);
}
+43
View File
@@ -20,6 +20,19 @@ namespace gclo;
/// </summary>
public static class Program
{
/// <summary>ASFW_ANY: any process may bring its window to the front.</summary>
private const int AnyProcess = -1;
/// <summary>
/// This process's claim on the data folder (#216): held from here until the
/// main window closes. Null only before <see cref="Main"/> has made it.
/// </summary>
internal static WindowClaim? Claim { get; private set; }
[STAThread]
private static void Main()
{
@@ -34,6 +47,31 @@ public static class Program
GcloPaths.Edition = "dev";
}
/////////////////////////////////////////////////////////////////////////////
// One window per data folder (#216). Another window on this folder would //
// keep its own copy of the settings and accounts and undo what this one //
// saves. The one that is open is asked to come to the front (this process //
// was started by the user, so it may pass that right on), and this one //
// ends before any window or file of its own exists. //
/////////////////////////////////////////////////////////////////////////////
try
{
Claim = WindowClaim.For(GcloPaths.DataRoot);
}
catch(Exception)
{
// No log exists yet, and nothing here is worth not opening for: without a claim the window opens as it always did.
Claim = null;
}
if(Claim is { IsTheOnlyOne: false })
{
_ = AllowSetForegroundWindow(AnyProcess);
Claim.AskTheOtherToShowItself();
Claim.Dispose();
return;
}
// The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim.
global::WinRT.ComWrappersSupport.InitializeComWrappers();
global::Microsoft.UI.Xaml.Application.Start((p) =>
@@ -43,4 +81,9 @@ public static class Program
_ = new App();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int AllowSetForegroundWindow(int processId);
}
+51 -2
View File
@@ -30,12 +30,22 @@
ToolTipService.ToolTip="Choose the default target folder"
Click="BrowseButton_Click" />
</Grid>
<!-- Default tokens (#212): one for GitHub, one for a Gitea server, named as
everywhere else. Each belongs to its place and is used for no other. -->
<StackPanel Spacing="2">
<TextBlock Style="{ThemeResource BodyStrongTextBlockStyle}" Text="Default tokens" />
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Quick Sync fills in the default token of the place it is on, and an account can use the one of its place. Stored in Windows Credential Manager, never in a file." />
</StackPanel>
<PasswordBox
x:Name="DefaultTokenBox"
Header="Default GitHub token"
Header="GitHub"
AutomationProperties.AutomationId="SettingsDefaultTokenBox"
PlaceholderText="ghp_…"
ToolTipService.ToolTip="Pre-fills Quick Sync and serves every account set to use it. Stored in Windows Credential Manager, never in a file."
ToolTipService.ToolTip="The default token for GitHub: pre-fills Quick Sync when it is on GitHub and serves every GitHub account set to use it."
PasswordChanged="DefaultTokenBox_PasswordChanged" />
<!-- Caption takes the width, the Remove link keeps its own: a horizontal
StackPanel clipped the link once the caption grew (#101). -->
@@ -61,6 +71,45 @@
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
Click="RemoveTokenButton_Click" />
</Grid>
<!-- Gitea: the token is for one server, so the address and the token are
saved, replaced and removed together. -->
<TextBox
x:Name="GiteaServerBox"
Header="Gitea"
AutomationProperties.AutomationId="SettingsGiteaServerBox"
PlaceholderText="Server address, like https://git.example.com"
ToolTipService.ToolTip="The address of the Gitea (or Forgejo) server the default token below is for. HTTPS only; the token is used for this server and no other."
TextChanged="GiteaServerBox_TextChanged" />
<PasswordBox
x:Name="GiteaTokenBox"
AutomationProperties.AutomationId="SettingsGiteaTokenBox"
AutomationProperties.Name="Default token for the Gitea server"
PlaceholderText="Access token of that server"
ToolTipService.ToolTip="The default token for the Gitea server above: pre-fills Quick Sync when it is on that server and serves every account on it that is set to use the default token."
PasswordChanged="GiteaTokenBox_PasswordChanged" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
x:Name="GiteaStateText"
Grid.Column="0"
VerticalAlignment="Center"
TextWrapping="Wrap"
AutomationProperties.AutomationId="SettingsGiteaStateText"
AutomationProperties.LiveSetting="Polite"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<HyperlinkButton
x:Name="RemoveGiteaTokenButton"
Grid.Column="1"
Padding="0"
VerticalAlignment="Center"
Content="Remove saved token"
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
Click="RemoveGiteaTokenButton_Click" />
</Grid>
<NumberBox
x:Name="ConcurrencyBox"
Header="Default parallelism"
+84 -3
View File
@@ -50,6 +50,13 @@ public sealed partial class SettingsDialog : ContentDialog
/// </summary>
private readonly SettingsTokenState _tokenState;
/// <summary>
/// The same for the Gitea group (#212): the address of the server and its default
/// token, which are saved, replaced and removed together. The saved token is never
/// carried to another address.
/// </summary>
private readonly SettingsGiteaState _giteaState;
/// <summary>
@@ -64,8 +71,14 @@ public sealed partial class SettingsDialog : ContentDialog
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());
hasSavedToken: DefaultTokens.For(vault, ForgeEndpoint.GitHub) is not null,
dependentAccounts: accounts.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Select(a => a.Name).ToList());
ForgeEndpoint? giteaPlace = settings.DefaultGiteaPlace();
_giteaState = new SettingsGiteaState(
giteaPlace,
hasSavedToken: giteaPlace is not null && DefaultTokens.For(vault, giteaPlace) is not null,
dependentAccounts: giteaPlace is null ? [] : accounts.UsingDefaultTokenOf(giteaPlace).Select(a => a.Name).ToList(),
holdsATokenFor: place => HoldsADefaultToken(vault, place));
ArgumentNullException.ThrowIfNull(windowHandleProvider);
_settings = settings;
_vault = vault;
@@ -89,6 +102,9 @@ public sealed partial class SettingsDialog : ContentDialog
// removes it. The caption follows every edit (PasswordChanged).
DefaultTokenBox.Password = _tokenState.InitialBoxValue;
UpdateTokenCaption();
GiteaServerBox.Text = _giteaState.InitialServer;
GiteaTokenBox.Password = _giteaState.InitialBoxValue;
UpdateGiteaCaption();
}
@@ -154,13 +170,22 @@ public sealed partial class SettingsDialog : ContentDialog
}
}
// The Gitea default: the token to the vault, the address of its server to the
// settings, which are written just below. Save is not offered while the group
// cannot be saved as it stands (UpdateGiteaCaption).
string giteaToken = GiteaTokenBox.Password;
if(_giteaState.Apply(_giteaState.Plan(GiteaServerBox.Text, giteaToken), giteaToken, _vault, _settings) is { } giteaChange)
{
changes.Add(giteaChange);
}
bool written = _settings.Save(_log);
string typed = DefaultTokenBox.Password;
switch(_tokenState.Plan(typed))
{
case SettingsTokenAction.Store:
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
_vault.Store(AppSettings.DefaultTokenVaultId, SettingsTokenState.WithoutTheMask(typed));
changes.Add(_tokenState.HasSavedToken ? "default token replaced" : "default token stored");
break;
case SettingsTokenAction.Remove:
@@ -188,11 +213,67 @@ public sealed partial class SettingsDialog : ContentDialog
// A box that shows the mask and is typed into without being emptied first holds
// the mask and then the typing. The box is left as it is; the state objects take
// the mask out where they read it, and it is taken out of what is stored.
private void DefaultTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
=> UpdateTokenCaption();
/// <summary>
/// Whether the vault holds a default token for a Gitea server, asked while its
/// address is typed. A credential store that cannot be read just then holds none
/// as far as the caption goes: nothing is lost by that, and typing is not broken.
/// </summary>
private static bool HoldsADefaultToken(ITokenVault vault, ForgeEndpoint place)
{
try
{
return DefaultTokens.For(vault, place) is not null;
}
catch(System.ComponentModel.Win32Exception)
{
return false;
}
}
private void RemoveGiteaTokenButton_Click(object sender, RoutedEventArgs e)
=> GiteaTokenBox.Password = "";
private void GiteaTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
=> UpdateGiteaCaption();
private void GiteaServerBox_TextChanged(object sender, TextChangedEventArgs e)
=> UpdateGiteaCaption();
/// <summary>
/// The caption of the Gitea group follows its two boxes: the state object words
/// what Save will do, or why the group cannot be saved as it stands. In that case
/// Save is not offered, so nothing is half saved; a warning (that, or a removal
/// that breaks accounts) is shown in the critical colour.
/// </summary>
private void UpdateGiteaCaption()
{
string server = GiteaServerBox.Text;
string box = GiteaTokenBox.Password;
GiteaStateText.Text = _giteaState.Caption(server, box);
GiteaStateText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[
_giteaState.IsWarning(server, box) ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"];
RemoveGiteaTokenButton.Visibility = _giteaState.CanOfferRemoval(box) ? Visibility.Visible : Visibility.Collapsed;
IsPrimaryButtonEnabled = !_giteaState.Plan(server, box).IsError;
}
/// <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
+42 -15
View File
@@ -84,20 +84,36 @@
load that fails (connect card still showing) and a refresh that fails
(table showing) get the same red, dismissable surface with a Retry — not
caption text at the bottom of the window. -->
<InfoBar
Grid.Row="0"
AutomationProperties.AutomationId="LoadErrorBar"
Severity="Error"
IsOpen="{x:Bind ViewModel.LoadErrorOpen, Mode=TwoWay}"
Title="{x:Bind ViewModel.LoadErrorTitle, Mode=OneWay}"
Message="{x:Bind ViewModel.LoadErrorMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="LoadErrorRetryButton"
Content="Retry"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<StackPanel Grid.Row="0">
<InfoBar
AutomationProperties.AutomationId="LoadErrorBar"
Severity="Error"
IsOpen="{x:Bind ViewModel.LoadErrorOpen, Mode=TwoWay}"
Title="{x:Bind ViewModel.LoadErrorTitle, Mode=OneWay}"
Message="{x:Bind ViewModel.LoadErrorMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="LoadErrorRetryButton"
Content="Retry"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<!-- Quick Sync, once its connection loaded repositories (#209): the moment
the user knows it is worth keeping. Quick Sync itself keeps no token. -->
<InfoBar
AutomationProperties.AutomationId="SaveAsAccountBar"
Severity="Informational"
IsOpen="{x:Bind ViewModel.SaveAsAccountOffered, Mode=TwoWay}"
Title="This connection works"
Message="Save it as an account to keep it: its token is then stored in Windows Credential Manager, and it opens with one click next time.">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="SaveAsAccountBarButton"
Content="Save as account…"
Click="SaveAsAccountButton_Click" />
</InfoBar.ActionButton>
</InfoBar>
</StackPanel>
<!-- ============================================================ State 1
Connect card, shown until the first successful load in this workspace. -->
@@ -130,7 +146,7 @@
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Once this connection works, save it as an account (Edit on the connection chip) so the token is kept for you."
Text="Quick Sync keeps no token. Once this connection works, you can save it as an account, where the token is kept for you."
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(IsAccountWorkspace), Mode=OneTime}" />
<!-- A saved account: its token is not typed here (#182). -->
<TextBlock
@@ -214,6 +230,17 @@
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="·" />
<!-- Where the organization is: GitHub, or the host of the Gitea server. -->
<TextBlock
AutomationProperties.AutomationId="ChipPlaceText"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.PlaceName, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="·" />
<TextBlock
x:Name="ChipRepoCountText"
VerticalAlignment="Center"
+4 -6
View File
@@ -453,12 +453,10 @@ public sealed partial class WorkspacePage : UserControl
EditFlyout.Hide();
if(SaveAsAccountRequested is { } save)
{
await save(new AccountWizardSeed(
ViewModel.Token,
ViewModel.Organization,
ViewModel.TargetFolder,
ViewModel.CreateOrgSubfolder,
ViewModel.MaxConcurrency));
if(ViewModel.ToAccountSeed() is { } seed)
{
await save(seed);
}
}
}