Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 1dce803d63 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
From the review of #210.

- An address that was edited but not taken yet (the box was not left: a
  keyboard shortcut moves no focus) was still the place of a load started
  with Ctrl+R. The card now names no usable place while its address box holds
  another text than the address that was taken.
- An address made unusable after a load left the old place's table syncable,
  and a token typed next went to the old server's clone addresses. Sync,
  Retry failed and path recovery need a usable place, as Load and Find do; the
  commands check for themselves, whatever started them; no account is offered
  from a card that names no place.
- A token or organization put on the card before a place was chosen is not
  kept for the place that is: the view model no longer relies on the shell's
  disabled token box.
- The form follows its view model only while it is in the tree, and its own
  changes of the token box are not taken for typing.
- The workspace that was open is written when the window closes, not on every
  switch, and what the app remembers by itself is not written over a settings
  file that could not be read.
- The offer to save the connection is made once; a failure to remember the
  connection does not fail the load.
- README, ADR 0010, T13.

Refs #209

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:24:54 -06:00
james.maesandClaude Fable 5.2 05f1bd290f fix(app): no place is chosen by keyboard focus, and the pane's button closes and opens the pane
- Quick Sync's choice of place was a RadioButtons group. That control selects
  its first option as soon as keyboard focus lands on it, and the window opens
  with the focus there: GitHub was chosen before the user did anything, and a
  token typed next was for a place nobody chose. The choice is now two radio
  buttons of their own, which are checked by an act (a click, Space, an arrow
  key, UI automation) and never by focus.
- The navigation pane was set to stay open, so its button did nothing. The
  pane now closes to its icons and opens again, and has a title beside the
  button.
- UI suite: a launched app of its own for the first run (no place chosen,
  focus chooses none, choosing is an act), and a test for the pane's button.

Refs #209
Closes #213

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:24:54 -06:00
james.maesandClaude Fable 5.2 74d7590c58 feat(app): Quick Sync connects to GitHub or Gitea, sends a token only when asked, and the app opens where it was left
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.

Closes #209

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:24:53 -06:00
22 changed files with 2369 additions and 103 deletions

No files matched your search

+5 -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`,
+12 -11
View File
@@ -85,32 +85,33 @@ gclo authenticates to GitHub with a personal access token — it is used for bot
## 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`).
@@ -124,7 +125,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
### 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" />
+32 -1
View File
@@ -130,7 +130,7 @@ 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
@@ -142,6 +142,37 @@ were the two lister implementations and the failure type they throw.
- **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.
## 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

+3 -2
View File
@@ -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; 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. 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 (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,4 @@ 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). |
File diff suppressed because it is too large. Load diff
+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(
+85 -2
View File
@@ -197,7 +197,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,7 +292,86 @@ 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();
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.
+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} }}";
}
+5 -1
View File
@@ -144,10 +144,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Name = "";
Description = "";
Token = seed.Token;
// 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 becomes a
// default-token account; any other token is the account's own. Compared in
// memory only; neither value is logged.
// memory only; neither value is logged. The default token is a GitHub token.
UseDefaultToken = HasDefaultToken
&& _chosen.Kind == ForgeKind.GitHub
&& string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal);
Organization = seed.Organization;
TargetRoot = seed.TargetRoot;
+91
View File
@@ -37,6 +37,32 @@ public sealed class AppSettings
/// </summary>
public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11");
/// <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 +112,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 +120,29 @@ 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>
/// Writes the settings because the app remembered something by itself (the
/// workspace that was open, Quick Sync's last connection), not because the user
/// saved them. Does nothing when the file could not be read at start: that file
/// is not written over with defaults on the app's own account.
/// </summary>
public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null)
=> !CouldNotBeRead && 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 +220,51 @@ public sealed class AppSettings
/// <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;
}
/// <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.
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// The default token saved in Settings for a place, which Quick Sync fills in when
/// it is on that place. A default token belongs to one place and is never offered
/// for another.
/// </summary>
public static class DefaultTokens
{
/// <summary>
/// The default token for <paramref name="place"/>, or null when none is saved for
/// it. Today Settings holds one for GitHub only.
/// </summary>
public static string? For(ITokenVault vault, ForgeEndpoint place)
{
ArgumentNullException.ThrowIfNull(vault);
ArgumentNullException.ThrowIfNull(place);
if(place.Kind != ForgeKind.GitHub)
{
return null;
}
string? token = vault.TryRetrieve(AppSettings.DefaultTokenVaultId);
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);
+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;
}
}
+448 -28
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
@@ -665,8 +1032,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
RefreshAccountToken();
}
else if(string.Equals(Token, previous ?? "", StringComparison.Ordinal))
else if(_placeIsUsable && _quickSyncPlace.Kind == ForgeKind.GitHub && string.Equals(Token, previous ?? "", StringComparison.Ordinal))
{
// The default token is a GitHub token: a Quick Sync that is on another place is not given it.
SetTokenFromItsSource(current ?? "");
}
}
@@ -760,8 +1128,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 +1151,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 +1175,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 +1204,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 +1350,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& !string.IsNullOrWhiteSpace(Organization)
&& !string.IsNullOrWhiteSpace(Token);
@@ -973,6 +1360,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 +1410,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 +1457,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& !IsTableStale
&& !string.IsNullOrWhiteSpace(TargetFolder)
&& Repos.Any(r => r.IsSelected);
@@ -1055,6 +1468,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 +1591,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& _placeIsUsable
&& Repos.Any(r => r.Status == SyncStatus.Failed);
@@ -1251,7 +1671,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 +1682,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;
}
+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"
+242 -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,244 @@ 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 not null && !string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
{
ViewModel.ServerIsBeingEdited();
}
}
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"
+42 -12
View File
@@ -161,11 +161,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
@@ -198,6 +205,10 @@ public sealed partial class MainWindow : Window
Closed += (_, _) =>
{
if(_settings.LastWorkspace != workspaceAtStart)
{
_settings.SaveWhatWasRemembered(_log);
}
responsiveness.Dispose();
_logWindow?.Close(); // a log-only process would linger otherwise
DisposeWorkspaces();
@@ -399,6 +410,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 +555,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;
}
@@ -859,9 +884,10 @@ 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,
_accountsStore, UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place), seed, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
@@ -877,6 +903,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
+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);
}
}
}