diff --git a/CLAUDE.md b/CLAUDE.md index 6397438..c47248e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, diff --git a/README.md b/README.md index 2760e8c..3c58236 100644 --- a/README.md +++ b/README.md @@ -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:

gclo startup splash    - Quick Sync connect card: personal access token, organization dropdown, target folder with subfolder toggle and path preview, and Load repositories + 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

-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 `` 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 `` 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.

Loaded repository table: 59 repositories all selected, with sortable, filterable Name / Status / Branch / Archived columns and a Sync 59 repos button

-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.

Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59

-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//` inside WSL (reachable from Explorer as `\wsl.localhost\\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.

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 diff --git a/docs/adr/0010-forges-beside-github.md b/docs/adr/0010-forges-beside-github.md index dc3d273..e170610 100644 --- a/docs/adr/0010-forges-beside-github.md +++ b/docs/adr/0010-forges-beside-github.md @@ -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 diff --git a/docs/images/gclo-connect.png b/docs/images/gclo-connect.png index fbf9ebb..e9c0236 100644 Binary files a/docs/images/gclo-connect.png and b/docs/images/gclo-connect.png differ diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 848c434..60022ab 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -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://.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). | diff --git a/gclo.Engine.Tests/QuickSyncPlaceTests.cs b/gclo.Engine.Tests/QuickSyncPlaceTests.cs new file mode 100644 index 0000000..cb78f6e --- /dev/null +++ b/gclo.Engine.Tests/QuickSyncPlaceTests.cs @@ -0,0 +1,1094 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Collections.Concurrent; +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +///

+/// Quick Sync's place, chosen on its card (#209): GitHub, or Gitea and the server's +/// address. The rules these tests are built around: nothing is sent anywhere +/// before the user chose a place and asked (a token that is typed, pasted or filled +/// in goes nowhere by itself); a token is handed only to the listers of the place +/// the card is on; and what was entered or loaded for one place is not carried to +/// another. Each lister here records what it was given +/// under the place it is the lister of. Also what the app remembers of a Quick Sync +/// connection between runs (never a token), and which workspace it opens on. +/// +public sealed class QuickSyncPlaceTests : IDisposable +{ + private const string Server = "https://git.example.test/"; + + private static readonly ForgeEndpoint s_gitea = Place(Server); + + private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + /// Every token a lister was given: the place the lister is for, what was asked, and the token. + private readonly ConcurrentQueue<(string Place, string Asked, string Token)> _sent = new(); + + private readonly List _workspaces = []; + + /// The git client of every Quick Sync made by : what it was asked to clone. + private readonly FakeGitClient _git = new(); + + /// How many organizations a lister lists; one means "only your own account". + private int _organizations = 2; + + + + public void Dispose() + { + foreach(WorkspaceViewModel workspace in _workspaces) + { + workspace.Dispose(); + } + GitTestHelpers.TryDeleteDirectory(_root); + } + + + + private static ForgeEndpoint Place(string server) + { + Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out _)); + return place; + } + + + + /// The listers of a place: they record what they are given and answer with things named after the place. + private (IRepositoryLister Repositories, IOrganizationLister Organizations) ListersOf(ForgeEndpoint place) + { + var repositories = new RecordingRepositoryLister(place.DisplayName, _sent); + var organizations = new FakeOrganizationLister + { + Handler = (token, _) => + { + _sent.Enqueue((place.DisplayName, "organizations", token)); + IReadOnlyList listed = _organizations == 1 ? ["me"] : ["me", $"org-at-{place.DisplayName}"]; + return Task.FromResult(listed); + }, + }; + return (repositories, organizations); + } + + + + private WorkspaceViewModel QuickSync( + Func? defaultTokenOf = null, + Func? listersOf = null) + { + var workspace = new WorkspaceViewModel( + git: _git, + progressFactory: handler => new Progress(handler), + orgLookupDebounce: TimeSpan.FromMilliseconds(1), + log: new NullActivityLog(), + wsl: new FakeWslCloner(), + listersOf: listersOf ?? ListersOf, + defaultTokenOf: defaultTokenOf); + _workspaces.Add(workspace); + return workspace; + } + + + + /// Long enough for a lookup that was going to be sent to have been sent (the debounce is 1 ms). + private static Task ALookupWouldHaveGoneOutAsync() => Task.Delay(150); + + + + /// A Quick Sync on which GitHub was chosen. + private WorkspaceViewModel QuickSyncOnGitHub(Func? defaultTokenOf = null) + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf); + workspace.PlaceIndex = 0; + return workspace; + } + + + + /// Types a token and asks for the organizations it lists, as the Find button does. + private static async Task TypeTokenAsync(WorkspaceViewModel workspace, string token) + { + workspace.Token = token; + Assert.True(workspace.FindOrganizationsCommand.CanExecute(null)); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + Assert.NotEmpty(workspace.Organizations); + } + + + + // ---------------------------------------------------------------- the choice + + [Fact] + public async Task QuickSync_StartsWithNoPlaceChosen_AndNothingCanBeSent() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789"); + + Assert.True(workspace.CanChoosePlace); + Assert.False(workspace.IsPlaceChosen); + Assert.Equal(-1, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + Assert.Equal("Connect", workspace.ConnectTitle); + Assert.Equal("", workspace.PlaceName); + Assert.Equal("Access token", workspace.TokenHeader); + Assert.Equal("", workspace.TokenPlaceholder); + Assert.Null(workspace.TokenPage); + Assert.False(workspace.SaveAsAccountOffered); + + // No place was chosen: no default token is filled in, and one that is put in the box goes nowhere. + workspace.FillInTheDefaultToken(); + Assert.Equal("", workspace.Token); + + workspace.Token = "a-token-0123456789"; + workspace.Organization = "typed-by-hand"; + workspace.Server = Server; // an address without the choice is no place either + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.False(workspace.IsPlaceChosen); + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); // no connection to save + + // And when something runs the commands without asking whether they can: still nothing. + await workspace.LoadReposCommand.ExecuteAsync(null); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Empty(_sent); + Assert.False(workspace.HasLoadedRepos); + } + + + + [Fact] + public void WhatWasPutOnTheCardBeforeAPlaceWasChosen_IsNotKeptForThePlaceThatIs() + { + // The shell's token box takes no token before a place is chosen; the view model does not rely on that. + WorkspaceViewModel workspace = QuickSync(); + workspace.Token = "a-token-0123456789"; + workspace.Organization = "typed-by-hand"; + + workspace.PlaceIndex = 0; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(_sent); + } + + + + [Fact] + public void ChoosingGitHub_IsAChoiceToo() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.PlaceIndex = 0; + + Assert.True(workspace.IsPlaceChosen); + Assert.Equal(0, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.Equal("Connect to GitHub", workspace.ConnectTitle); + Assert.Equal("Personal access token", workspace.TokenHeader); + Assert.Equal("ghp_…", workspace.TokenPlaceholder); + Assert.Equal(new Uri("https://github.com/settings/tokens"), workspace.TokenPage); + Assert.Contains(nameof(WorkspaceViewModel.IsPlaceChosen), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.ConnectTitle), changed, StringComparer.Ordinal); + + // The default token saved for the place that was chosen is filled in. It is not sent. + Assert.Equal("ghp_default_0123456789", workspace.Token); + Assert.Empty(_sent); + } + + + + [Fact] + public async Task ATokenThatIsTypedPastedOrFilledIn_GoesNowhere_UntilTheUserAsks() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + + workspace.Token = "ghp_token_0123456789"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.Empty(workspace.Organizations); + + // Find: the organizations are asked for, now, of the place that is on the card. + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Equal([("GitHub", "organizations", "ghp_token_0123456789")], _sent); + Assert.Equal(["me", "org-at-GitHub"], workspace.Organizations); + Assert.Equal("typed-by-hand", workspace.Organization); // what was typed stays + + // Another token: the list of the other one goes, and again nothing is sent by itself. + workspace.Token = "ghp_other_0123456789"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(workspace.Organizations); + Assert.Single(_sent); + + // Too short to be a token: there is nothing to ask with. + workspace.Token = "ghp_short"; + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + } + + + + [Fact] + public void PlaceIndex_IsTheChoiceAsARadioGroupReadsIt_AndNoSelectionChangesNothing() + { + WorkspaceViewModel workspace = QuickSync(); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.PlaceIndex = -1; // a radio group with no selection: still nothing chosen + Assert.False(workspace.IsPlaceChosen); + + workspace.PlaceIndex = 1; + + Assert.True(workspace.IsOnGitea); + Assert.True(workspace.AsksForServer); + Assert.Equal(1, workspace.PlaceIndex); + Assert.Contains(nameof(WorkspaceViewModel.PlaceIndex), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.AsksForServer), changed, StringComparer.Ordinal); + + workspace.PlaceIndex = -1; // a radio group that has no selection for a moment + + Assert.True(workspace.IsOnGitea); + + workspace.PlaceIndex = 0; + + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + } + + + + [Fact] + public async Task Gitea_WithoutAnAddressYet_IsNoPlace_AndNothingIsSent() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.IsOnGitea = true; + + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + Assert.Equal("Access token", workspace.TokenHeader); + Assert.Equal("", workspace.TokenPlaceholder); + Assert.Null(workspace.TokenPage); // no server, no page of it + Assert.Equal("", workspace.ServerError); // nothing was entered yet: nothing to complain about + + workspace.Token = "a-token-typed-too-early"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.Empty(workspace.Organizations); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + } + + + + // ---------------------------------------------------------------- where the token goes + + [Fact] + public async Task AConnectionToAGiteaServer_SendsItsTokenThere_AndNowhereElse() + { + WorkspaceViewModel workspace = QuickSync(); + QuickSyncConnection? connected = null; + workspace.Connected = connection => connected = connection; + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = " git.example.test "; // as it is typed + + // The address reads as it was normalized: it is the one a token goes to. + Assert.Equal(Server, workspace.Server); + Assert.Equal("", workspace.ServerError); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal(new Uri("https://git.example.test/user/settings/applications"), workspace.TokenPage); + Assert.All( + (string[]) + [ + nameof(WorkspaceViewModel.Server), nameof(WorkspaceViewModel.PlaceName), nameof(WorkspaceViewModel.ConnectTitle), + nameof(WorkspaceViewModel.TokenHeader), nameof(WorkspaceViewModel.TokenPlaceholder), nameof(WorkspaceViewModel.TokenPage), + ], + name => Assert.Contains(name, changed, StringComparer.Ordinal)); + Assert.Empty(_sent); + + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + + Assert.Equal(["me", "org-at-git.example.test"], workspace.Organizations); + Assert.Equal("Found 2 organizations and accounts.", workspace.StatusText); + + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + workspace.CreateOrgSubfolder = true; + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.Equal( + [("git.example.test", "organizations", "gitea-token-0123456789"), ("git.example.test", "repositories", "gitea-token-0123456789")], + _sent); + Assert.True(workspace.HasLoadedRepos); + + // The connection works: offered as an account, and told to whoever remembers it. No token in what is told. + Assert.True(workspace.SaveAsAccountOffered); + Assert.Equal(new QuickSyncConnection(s_gitea, "org-at-git.example.test", @"C:\mirror", CreateOrgSubfolder: true), connected); + } + + + + [Theory] + [InlineData("http://git.example.test", "The server address cannot be used: it has to begin with https://.")] + [InlineData( + "https://someone:s3cret-token@git.example.test", + "The server address cannot be used: it contains a user name or a password; the token is entered separately.")] + public async Task AnAddressThatCannotBeUsed_IsSaidSo_AndNothingIsSent(string typed, string said) + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + + workspace.Server = typed; + workspace.Token = "gitea-token-0123456789"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Equal(said, workspace.ServerError); + Assert.DoesNotContain("s3cret", workspace.ServerError, StringComparison.Ordinal); // a refused address is not repeated + Assert.Equal(typed, workspace.Server); // left as it was typed, to be corrected + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + Assert.Empty(_sent); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + + // Corrected, the message goes away and the place is taken. What was typed before there was a place is not kept for it. + workspace.Server = "git.example.test"; + + Assert.Equal("", workspace.ServerError); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal("", workspace.Token); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + + workspace.Token = "gitea-token-0123456789"; + workspace.Organization = "typed-by-hand"; + + Assert.True(workspace.LoadReposCommand.CanExecute(null)); + Assert.True(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.Empty(_sent); + } + + + + [Fact] + public async Task TheOrganizationHint_IsInTheWordsOfTheForge() + { + _organizations = 1; // a token that sees no organization + WorkspaceViewModel onGitHub = QuickSyncOnGitHub(); + WorkspaceViewModel onGitea = QuickSync(); + onGitea.IsOnGitea = true; + onGitea.Server = Server; + + onGitHub.Token = "ghp_token_0123456789"; + onGitea.Token = "gitea-token-0123456789"; + await onGitHub.FindOrganizationsCommand.ExecuteAsync(null); + await onGitea.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Equal( + "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually.", + onGitHub.StatusText); + Assert.Equal( + "Only your personal account is visible — allow the token read:organization for organizations, or type an org name manually.", + onGitea.StatusText); + } + + + + // ---------------------------------------------------------------- nothing is carried to another place + + [Fact] + public async Task WhatWasEnteredAndLoadedForOnePlace_IsForgotten_WhenAnotherIsChosen() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + await workspace.LoadReposCommand.ExecuteAsync(null); + Assert.Equal(2, workspace.Repos.Count); + Assert.True(workspace.SaveAsAccountOffered); + + // The other kind of forge: everything of GitHub goes at once, before there is an address. + workspace.IsOnGitea = true; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(workspace.Organizations); + Assert.Empty(workspace.Repos); // their clone addresses are on github.com: not to be synced with a token of the server + Assert.Empty(workspace.FilteredRepos); + Assert.False(workspace.HasLoadedRepos); + Assert.Null(workspace.LoadedOrganization); + Assert.False(workspace.SaveAsAccountOffered); + Assert.False(workspace.SyncCommand.CanExecute(null)); + + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + await workspace.LoadReposCommand.ExecuteAsync(null); + int sentSoFar = _sent.Count; + + // From one server to another: the same. + workspace.Server = "https://other.example.test"; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(workspace.Repos); + Assert.Equal("Connect to other.example.test", workspace.ConnectTitle); + await ALookupWouldHaveGoneOutAsync(); + Assert.Equal(sentSoFar, _sent.Count); // nothing went to the new server: no token was typed for it + + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "git.example.test", StringComparison.Ordinal) && sent.Token.StartsWith("ghp_", StringComparison.Ordinal)); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "GitHub", StringComparison.Ordinal) && sent.Token.StartsWith("gitea", StringComparison.Ordinal)); + } + + + + [Fact] + public async Task AnAddressThatIsBeingTyped_IsNoPlace_UntilItIsTakenAgain() + { + // The card shows another text than the address that was taken: a keyboard shortcut moves no focus, so the + // box was not left. Nothing uses the address that is no longer on screen. + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + await workspace.LoadReposCommand.ExecuteAsync(null); + int sentSoFar = _sent.Count; + Assert.True(workspace.SyncCommand.CanExecute(null)); + + workspace.ServerIsBeingEdited(); + + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.SyncCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); + Assert.False(workspace.SaveAsAccountOffered); + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + + await workspace.LoadReposCommand.ExecuteAsync(null); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + await workspace.SyncCommand.ExecuteAsync(null); + + Assert.Equal(sentSoFar, _sent.Count); + Assert.Empty(_git.CloneCalls); + + // The same address after all: the place is taken again, and nothing was forgotten. + workspace.Server = Server; + + Assert.Equal("gitea-token-0123456789", workspace.Token); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.True(workspace.SyncCommand.CanExecute(null)); + Assert.NotNull(workspace.ToAccountSeed()); + + workspace.ServerIsBeingEdited(); + workspace.ServerIsBeingEdited(); // said for every keystroke + workspace.Server = "https://other.example.test"; // another address after all + + Assert.Equal("", workspace.Token); + Assert.Empty(workspace.Repos); + } + + + + [Fact] + public async Task AfterALoad_AnAddressThatCannotBeUsedAnyMore_LeavesNothingOfTheOldPlaceToRun() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + await workspace.LoadReposCommand.ExecuteAsync(null); + workspace.Repos[0].Status = SyncStatus.Failed; + workspace.RetryFailedCommand.NotifyCanExecuteChanged(); + Assert.True(workspace.SyncCommand.CanExecute(null)); + Assert.True(workspace.RetryFailedCommand.CanExecute(null)); + int sentSoFar = _sent.Count; + + workspace.Server = "http://x"; // no address that can be used; the table still shows the old server's repositories + + Assert.False(workspace.SyncCommand.CanExecute(null)); + Assert.False(workspace.RetryFailedCommand.CanExecute(null)); + Assert.False(workspace.ResolvePathsCommand.CanExecute(workspace.Repos[0])); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); + Assert.False(workspace.SaveAsAccountOffered); + + // A token typed now is for no place: nothing clones the old server's repositories with it. + workspace.Token = "a-token-typed-now-0123456789"; + await workspace.SyncCommand.ExecuteAsync(null); + await workspace.RetryFailedCommand.ExecuteAsync(null); + await workspace.ResolvePathsCommand.ExecuteAsync(workspace.Repos[0]); + + Assert.Empty(_git.CloneCalls); + Assert.Equal(sentSoFar, _sent.Count); + } + + + + [Fact] + public async Task ALookupThatIsOnItsWay_IsEnded_WhenTheAddressIsEdited() + { + var answer = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => (ListersOf(place).Repositories, new FakeOrganizationLister + { + Handler = (_, canceled) => + { + canceled.Register(() => answer.TrySetCanceled(canceled)); + return answer.Task; + }, + })); + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.Token = "gitea-token-0123456789"; + Task asking = workspace.FindOrganizationsCommand.ExecuteAsync(null); + + workspace.ServerIsBeingEdited(); + await asking; + + Assert.True(answer.Task.IsCanceled); + Assert.Empty(workspace.Organizations); + Assert.False(workspace.IsLoadingOrgs); + } + + + + [Fact] + public async Task TheOfferToSaveTheConnection_IsMadeOnce_AndRememberingItFailingDoesNotFailTheLoad() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + workspace.Connected = _ => throw new InvalidOperationException("the settings could not be written"); + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.True(workspace.HasLoadedRepos); + Assert.False(workspace.LoadErrorOpen); + Assert.Equal("2 repositories loaded.", workspace.StatusText); + Assert.True(workspace.SaveAsAccountOffered); + + // Dismissed, it stays dismissed when the list is refreshed. + workspace.SaveAsAccountOffered = false; + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.False(workspace.SaveAsAccountOffered); + } + + + + [Fact] + public async Task TheSameServer_SpelledAnotherWay_IsTheSamePlace_AndNothingIsForgotten() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = "git.example.test"; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + + workspace.Server = "HTTPS://GIT.example.test"; + + Assert.Equal(Server, workspace.Server); + Assert.Equal("gitea-token-0123456789", workspace.Token); + Assert.Equal("org-at-git.example.test", workspace.Organization); + } + + + + [Fact] + public async Task APlaceWhoseListersCannotBeMade_IsNotTaken_AndNothingGoesToTheListersOfTheOther() + { + WorkspaceViewModel workspace = QuickSync( + listersOf: place => string.Equals(place.Server?.Host, "broken.example.test", StringComparison.Ordinal) ? throw new InvalidOperationException("no listers for that place") : ListersOf(place)); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + int sentSoFar = _sent.Count; + + Assert.Throws(() => workspace.Server = "https://broken.example.test"); + + // The card shows the new address, and there are no listers for it: the token that is still there can go nowhere. + workspace.Token = "gitea-token-0123456789-again"; + workspace.Organization = "typed-by-hand"; + + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Equal(sentSoFar, _sent.Count); + } + + + + [Fact] + public async Task WhileTheWorkspaceIsBusy_ThePlaceStaysAsItIs() + { + var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => (new GatedRepositoryLister(gate.Task), ListersOf(place).Organizations)); + workspace.PlaceIndex = 0; + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + Task loading = workspace.LoadReposCommand.ExecuteAsync(null); + Assert.False(workspace.CanEditInputs); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = Server; + + // Refused, and said: a control that asked shows the choice as it is. + Assert.False(workspace.IsOnGitea); + Assert.Equal("", workspace.Server); + Assert.Contains(nameof(WorkspaceViewModel.IsOnGitea), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.Server), changed, StringComparer.Ordinal); + Assert.Equal("ghp_token_0123456789", workspace.Token); + + gate.SetResult([new RepoDescriptor("alpha", "https://github.com/org-at-GitHub/alpha.git", "main", false)]); + await loading; + + Assert.Single(workspace.Repos); + } + + + + [Fact] + public async Task TheAnswerToALookup_ThatArrivesAfterAnotherPlaceWasChosen_IsNotShown() + { + var answer = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => place.Server is not null + ? ListersOf(place) + : (ListersOf(place).Repositories, new FakeOrganizationLister { Handler = (_, _) => answer.Task })); + workspace.PlaceIndex = 0; + workspace.Token = "ghp_token_0123456789"; + Task asking = workspace.FindOrganizationsCommand.ExecuteAsync(null); + + workspace.IsOnGitea = true; + workspace.Server = Server; + answer.SetResult(["me", "an-organization-on-github"]); + await asking; + + Assert.Empty(workspace.Organizations); // GitHub's organizations are not offered for the server + Assert.False(workspace.IsLoadingOrgs); + } + + + + // ---------------------------------------------------------------- the default token of a place + + [Fact] + public async Task TheDefaultTokenOfAPlace_IsFilledInForThatPlaceOnly() + { + var asked = new List(); + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => + { + asked.Add(place.DisplayName); + return place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null; + }); + + workspace.PlaceIndex = 0; + + Assert.Equal("ghp_default_0123456789", workspace.Token); + await ALookupWouldHaveGoneOutAsync(); + Assert.Empty(_sent); // filled in, not sent + + // On a Gitea server there is none: the box is empty, and GitHub's default token goes nowhere near it. + workspace.IsOnGitea = true; + workspace.Server = Server; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Equal("", workspace.Token); + Assert.Contains("git.example.test", asked, StringComparer.Ordinal); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "git.example.test", StringComparison.Ordinal)); + + // Back on GitHub it is filled in again. + workspace.IsOnGitea = false; + + Assert.Equal("ghp_default_0123456789", workspace.Token); + } + + + + [Fact] + public void TheDefaultToken_DoesNotReplaceATokenThatWasTyped_AndIsNotForAnAccount() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789"); + workspace.PlaceIndex = 0; + workspace.Token = "ghp_typed_0123456789"; + + workspace.FillInTheDefaultToken(); + + Assert.Equal("ghp_typed_0123456789", workspace.Token); + + var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\repos" }; + using var ofAnAccount = new WorkspaceViewModel( + new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister(), account: account, + listersOf: ListersOf, defaultTokenOf: _ => "ghp_default_0123456789"); + + ofAnAccount.FillInTheDefaultToken(); + ofAnAccount.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: false)); + ofAnAccount.IsOnGitea = true; + ofAnAccount.Server = Server; + + // An account's place and token are its own: none of this is for it. + Assert.False(ofAnAccount.CanChoosePlace); + Assert.Equal("", ofAnAccount.Token); + Assert.False(ofAnAccount.IsOnGitea); + Assert.Equal("", ofAnAccount.Server); + Assert.Equal("acme", ofAnAccount.Organization); + Assert.Equal("Connect to GitHub", ofAnAccount.ConnectTitle); + } + + + + [Fact] + public void AChangeOfTheDefaultTokenInSettings_ReachesQuickSync_OnlyWhileItIsOnGitHub() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789"); + Assert.Equal("", workspace.Token); // no place was chosen yet + + workspace.PlaceIndex = 0; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789"); + Assert.Equal("ghp_default_0123456789", workspace.Token); + + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789"); // the box is empty, as "previous" says + + Assert.Equal("", workspace.Token); // a GitHub token: not for this place + + workspace.Server = "http://not-usable"; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789"); + + Assert.Equal("", workspace.Token); + } + + + + // ---------------------------------------------------------------- opening the way it was left + + [Fact] + public async Task Restore_PutsTheCardBackAsAConnectionHadIt_WithoutAToken_AndSendsNothing() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true)); + await ALookupWouldHaveGoneOutAsync(); + + Assert.True(workspace.IsOnGitea); + Assert.Equal(Server, workspace.Server); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal("platform", workspace.Organization); + Assert.Equal(@"C:\mirror", workspace.TargetFolder); + Assert.True(workspace.CreateOrgSubfolder); + Assert.Equal("", workspace.Token); + Assert.False(workspace.HasLoadedRepos); // nothing is loaded until the user asks + Assert.Empty(_sent); + } + + + + [Fact] + public void Restore_OfAGitHubConnection_FillsInTheDefaultToken_AndLeavesAFolderThatWasNotRememberedAlone() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null); + workspace.TargetFolder = @"C:\from-settings"; + + workspace.Restore(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", TargetFolder: "", CreateOrgSubfolder: true)); + + Assert.False(workspace.IsOnGitea); + Assert.Equal("acme", workspace.Organization); + Assert.Equal(@"C:\from-settings", workspace.TargetFolder); + Assert.False(workspace.CreateOrgSubfolder); + Assert.Equal("ghp_default_0123456789", workspace.Token); + Assert.Throws(() => workspace.Restore(null!)); + } + + + + [Fact] + public void AQuickSyncThatWasGivenNoListersOfAPlace_OffersNoChoice() + { + using var workspace = new WorkspaceViewModel(new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister()); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: false)); + + Assert.False(workspace.CanChoosePlace); + Assert.True(workspace.IsPlaceChosen); // its place is given: GitHub + Assert.Equal(0, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + Assert.Equal("", workspace.Server); + Assert.Equal("", workspace.Organization); + Assert.Equal("Connect to GitHub", workspace.ConnectTitle); + Assert.Contains(nameof(WorkspaceViewModel.IsOnGitea), changed, StringComparer.Ordinal); // said all the same: the control shows what is + } + + + + // ---------------------------------------------------------------- saved as an account + + [Fact] + public async Task AConnectionSavedAsAnAccount_IsAnAccountOnThePlaceItWasOn() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + + AccountWizardSeed seed = Assert.IsType(workspace.ToAccountSeed()); + + Assert.Equal(s_gitea, seed.Place); + Assert.Equal("gitea-token-0123456789", seed.Token); + Assert.DoesNotContain("gitea-token", seed.ToString(), StringComparison.Ordinal); + Assert.Contains("Place = git.example.test", seed.ToString(), StringComparison.Ordinal); + Assert.Contains("Place = GitHub", (seed with { Place = null }).ToString(), StringComparison.Ordinal); + + // The wizard that saves it: on that place, with that place's lister, and never on the default token (a GitHub token). + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, _root); + var wizard = new AccountWizardViewModel( + store, ListersOf(s_gitea).Organizations, seed, defaultToken: () => "gitea-token-0123456789"); + Assert.False(wizard.CanChoosePlace); + Assert.Equal("This account is on git.example.test.", wizard.PlaceNote); + Assert.False(wizard.UseDefaultToken); + Assert.False(wizard.OffersTokenChoice); + + wizard.Name = "Platform"; + while(wizard.Step < 4) + { + Assert.True(await wizard.TryAdvanceAsync(), $"stopped at step {wizard.Step}"); + } + await wizard.SaveAsync(); + + Account saved = Assert.Single(new AccountsStore(vault, _root).GetAll()); + Assert.Equal(ForgeKind.Gitea, saved.Forge); + Assert.Equal(Server, saved.Server); + Assert.Equal("gitea-token-0123456789", vault.TryRetrieve(saved.TokenEntryId)); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "GitHub", StringComparison.Ordinal)); + } + + + + [Fact] + public async Task AnAccountsPage_IsNotOfferedToBeSavedAsAnAccount() + { + var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\repos" }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "ghp_token_0123456789"); + using var workspace = new WorkspaceViewModel( + new FakeRepositoryLister { Repositories = [new RepoDescriptor("alpha", "https://github.com/acme/alpha.git", "main", false)] }, + new FakeGitClient(), new FakeOrganizationLister(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: new NullActivityLog(), + account: account, tokenVault: vault); + bool told = false; + workspace.Connected = _ => told = true; + + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.True(workspace.HasLoadedRepos); + Assert.False(workspace.SaveAsAccountOffered); + Assert.False(told); + } + + + + // ---------------------------------------------------------------- what is remembered between runs + + [Fact] + public void AQuickSyncConnection_IsRememberedWithoutAToken_AndGivenBackAsItWas() + { + Directory.CreateDirectory(_root); + var settings = new AppSettings(); + settings.RememberQuickSync(new QuickSyncConnection(s_gitea, " platform ", @" C:\mirror ", CreateOrgSubfolder: true)); + settings.LastWorkspace = StartWorkspace.QuickSync; + Assert.True(settings.Save(directory: _root)); + + var loaded = AppSettings.Load(directory: _root); + + Assert.Equal(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true), loaded.RememberedQuickSync()); + Assert.Equal(StartWorkspace.QuickSync, loaded.LastWorkspace); + string file = File.ReadAllText(Path.Combine(_root, "settings.json")); + Assert.Contains("\"QuickSyncForge\": \"Gitea\"", file, StringComparison.Ordinal); + Assert.Contains("\"QuickSyncServer\": \"https://git.example.test/\"", file, StringComparison.Ordinal); + + // GitHub is remembered as no forge and no server. + loaded.RememberQuickSync(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false)); + + Assert.Null(loaded.QuickSyncForge); + Assert.Null(loaded.QuickSyncServer); + Assert.Equal(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false), loaded.RememberedQuickSync()); + Assert.Throws(() => loaded.RememberQuickSync(null!)); + } + + + + [Fact] + public void OnAFirstRun_NothingIsRemembered_SoNoPlaceIsChosenForTheUser() + { + var settings = new AppSettings(); + + Assert.Null(settings.RememberedQuickSync()); + Assert.Null(settings.LastWorkspace); + } + + + + [Theory] + [InlineData("Gitea", "http://git.example.test")] // a file edited by hand + [InlineData("Gitea", null)] + [InlineData("GitLab", "https://gitlab.example.test/")] // a forge of a later version + [InlineData(null, "https://git.example.test/")] // GitHub has no server + public void WhatIsRemembered_ThatNamesNoUsablePlace_IsNotGivenBack(string? forge, string? server) + { + var settings = new AppSettings { QuickSyncForge = forge, QuickSyncServer = server, QuickSyncOrganization = "platform" }; + + Assert.Null(settings.RememberedQuickSync()); + } + + + + [Fact] + public void ASettingsFileThatSaysNullForWhatIsRemembered_ReadsAsNothing() + { + Directory.CreateDirectory(_root); + File.WriteAllText(Path.Combine(_root, "settings.json"), """{ "QuickSyncOrganization": null, "QuickSyncTargetFolder": null }"""); + + var loaded = AppSettings.Load(directory: _root); + + Assert.Equal("", loaded.QuickSyncOrganization); + Assert.Equal("", loaded.QuickSyncTargetFolder); + } + + + + [Fact] + public void WhatTheAppRemembersByItself_IsNotWrittenOverASettingsFileThatCouldNotBeRead() + { + Directory.CreateDirectory(_root); + string file = Path.Combine(_root, "settings.json"); + File.WriteAllText(file, "{ \"Theme\": \"Dark\", this is where the file went wrong"); + + var settings = AppSettings.Load(directory: _root); + settings.RememberQuickSync(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false)); + + Assert.True(settings.CouldNotBeRead); + Assert.False(settings.SaveWhatWasRemembered(directory: _root)); + Assert.Contains("this is where the file went wrong", File.ReadAllText(file), StringComparison.Ordinal); // left for the user to mend + + // The Settings dialog's Save is the user's own doing, and writes. + Assert.True(settings.Save(directory: _root)); + Assert.DoesNotContain("went wrong", File.ReadAllText(file), StringComparison.Ordinal); + + // A file that was read, or was not there, is written. + var read = AppSettings.Load(directory: _root); + Assert.False(read.CouldNotBeRead); + read.LastWorkspace = StartWorkspace.QuickSync; + Assert.True(read.SaveWhatWasRemembered(directory: _root)); + Assert.Equal(StartWorkspace.QuickSync, AppSettings.Load(directory: _root).LastWorkspace); + } + + + + // ---------------------------------------------------------------- which workspace the app opens on + + [Fact] + public void TheAppOpens_WhereItWasLeft_AndOtherwiseOnTheFirstAccount_OrQuickSync() + { + Account first = new() { Id = Guid.NewGuid(), Name = "Alpha", Organization = "a", TargetRoot = "t" }; + Account second = new() { Id = Guid.NewGuid(), Name = "Beta", Organization = "b", TargetRoot = "t" }; + + // A first run, and any run without accounts: Quick Sync. + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([], last: null)); + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([], last: second.Id)); // the account that was open is gone + + // Accounts, and nothing recorded yet: the first of them. + Assert.Equal(first.Id, StartWorkspace.Choose([first, second], last: null)); + Assert.Equal(first.Id, StartWorkspace.Choose([first], last: null)); + + // Where it was left: an account, or Quick Sync. + Assert.Equal(second.Id, StartWorkspace.Choose([first, second], last: second.Id)); + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([first, second], last: StartWorkspace.QuickSync)); + + // The account that was open last was removed: the first of those that are left. + Assert.Equal(first.Id, StartWorkspace.Choose([first], last: second.Id)); + Assert.Throws(() => StartWorkspace.Choose(null!, last: null)); + } + + + + // ---------------------------------------------------------------- the default token saved for a place + + [Fact] + public void TheDefaultTokenInSettings_IsGitHubs_AndNoneForAnotherPlace() + { + var vault = new InMemoryVault(); + + Assert.Null(DefaultTokens.For(vault, ForgeEndpoint.GitHub)); + + vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default_0123456789"); + + Assert.Equal("ghp_default_0123456789", DefaultTokens.For(vault, ForgeEndpoint.GitHub)); + Assert.Null(DefaultTokens.For(vault, s_gitea)); + Assert.Throws(() => DefaultTokens.For(null!, ForgeEndpoint.GitHub)); + Assert.Throws(() => DefaultTokens.For(vault, null!)); + } + + + + // ---------------------------------------------------------------- fakes + + /// Lists two repositories on the place it is for, and records the token it was given. + private sealed class RecordingRepositoryLister(string place, ConcurrentQueue<(string Place, string Asked, string Token)> sent) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + sent.Enqueue((place, "repositories", token)); + IReadOnlyList listed = + [ + new("alpha", $"https://{place}/{organization}/alpha.git", "main", IsArchived: false), + new("bravo", $"https://{place}/{organization}/bravo.git", "main", IsArchived: false), + ]; + return Task.FromResult(listed); + } + } + + + + private sealed class Progress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + + + /// Answers when the test lets it. + private sealed class GatedRepositoryLister(Task> answer) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => answer; + } +} diff --git a/gclo.UiTests/FirstRunTests.cs b/gclo.UiTests/FirstRunTests.cs new file mode 100644 index 0000000..91d89a1 --- /dev/null +++ b/gclo.UiTests/FirstRunTests.cs @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using FlaUI.Core.AutomationElements; + + +namespace gclo.UiTests; + + +/// +/// 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. +/// +[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")); + } +} + + + +/// +/// A third collection (and therefore a third launched app), whose Quick Sync card +/// nobody touched; see . +/// +[CollectionDefinition(nameof(UiFirstRunTests), DisableParallelization = true)] +public sealed class UiFirstRunTests : ICollectionFixture +{ +} diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs index 6b42cf0..b76cf56 100644 --- a/gclo.UiTests/RepoTableTests.cs +++ b/gclo.UiTests/RepoTableTests.cs @@ -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( diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index f483e34..c2d66ce 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -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. diff --git a/gclo.ViewModels/AccountWizardSeed.cs b/gclo.ViewModels/AccountWizardSeed.cs index bc3f506..5d79964 100644 --- a/gclo.ViewModels/AccountWizardSeed.cs +++ b/gclo.ViewModels/AccountWizardSeed.cs @@ -3,6 +3,9 @@ * SPDX-License-Identifier: MIT */ +using gclo.Engine; + + namespace gclo.ViewModels; @@ -16,15 +19,17 @@ namespace gclo.ViewModels; /// The workspace's target folder (not the org subfolder). /// Whether clones go under TargetRoot\Organization. /// The workspace's parallel clone count. +/// Where the connection is: the place the token was used with. Null means GitHub. public sealed record AccountWizardSeed( string Token, string Organization, string TargetRoot, bool CreateOrgSubfolder, - int MaxConcurrency) + int MaxConcurrency, + ForgeEndpoint? Place = null) { /// Redacts the token: this record must never print it. public override string ToString() => $"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, " - + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}"; + + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency}, Place = {Place ?? ForgeEndpoint.GitHub} }}"; } diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index 52f9d8c..73ae639 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -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; diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 1d15691..5842654 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -37,6 +37,32 @@ public sealed class AppSettings /// public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"); + /// + /// The workspace that was open last: an account's id, or . + /// Null when nothing is recorded yet. See . + /// + public Guid? LastWorkspace { get; set; } + + /// + /// The forge Quick Sync last connected to, by name; null for GitHub. With + /// , , + /// and + /// it is what gives back. No token is among them. + /// + public string? QuickSyncForge { get; set; } + + /// The address of the Gitea server Quick Sync last connected to; null for GitHub. + public string? QuickSyncServer { get; set; } + + /// The organization Quick Sync last loaded; empty when none. + public string QuickSyncOrganization { get; set; } = ""; + + /// The target folder of Quick Sync's last connection; empty when none. + public string QuickSyncTargetFolder { get; set; } = ""; + + /// Whether Quick Sync's last connection cloned under an organization subfolder. + public bool QuickSyncCreateOrgSubfolder { get; set; } + /// Folder pre-filled as the sync target. Empty means "no default". 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 + /// + /// 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 + /// (): the file is left for the user to mend, + /// as it was before the app remembered anything. Saving the Settings dialog + /// still writes. + /// + [System.Text.Json.Serialization.JsonIgnore] + public bool CouldNotBeRead { get; private set; } + + + + /// + /// 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. + /// + public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null) + => !CouldNotBeRead && Save(log, directory); + + + /// How long a save tries to put the new file in place while something has the old one open. private static readonly TimeSpan s_savePatience = TimeSpan.FromSeconds(1); @@ -170,10 +220,51 @@ public sealed class AppSettings + /// + /// Records a Quick Sync connection that worked, so the card opens the same way + /// next time. The caller saves. Never a token. + /// + 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; + } + + + + /// + /// 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. + /// + 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. diff --git a/gclo.ViewModels/DefaultTokens.cs b/gclo.ViewModels/DefaultTokens.cs new file mode 100644 index 0000000..e63e13e --- /dev/null +++ b/gclo.ViewModels/DefaultTokens.cs @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// 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. +/// +public static class DefaultTokens +{ + /// + /// The default token for , or null when none is saved for + /// it. Today Settings holds one for GitHub only. + /// + 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; + } +} diff --git a/gclo.ViewModels/QuickSyncConnection.cs b/gclo.ViewModels/QuickSyncConnection.cs new file mode 100644 index 0000000..8fa75f7 --- /dev/null +++ b/gclo.ViewModels/QuickSyncConnection.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// 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. +/// +/// GitHub, or the Gitea server. +/// The organization (or user) that was loaded. +/// The target folder; empty when none was chosen. +/// Whether clones go under TargetFolder\Organization. +public sealed record QuickSyncConnection(ForgeEndpoint Place, string Organization, string TargetFolder, bool CreateOrgSubfolder); diff --git a/gclo.ViewModels/StartWorkspace.cs b/gclo.ViewModels/StartWorkspace.cs new file mode 100644 index 0000000..83aeea0 --- /dev/null +++ b/gclo.ViewModels/StartWorkspace.cs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// 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. +/// +public static class StartWorkspace +{ + /// The id that stands for Quick Sync, which has no account. + public static readonly Guid QuickSync = Guid.Empty; + + + + /// + /// The workspace to open: when it is Quick Sync or an + /// account that still exists; otherwise the first of + /// (they come sorted by name); otherwise Quick Sync. + /// + /// The saved accounts, in the order the pane lists them. + /// The workspace that was open last; null when nothing is recorded. + public static Guid Choose(IReadOnlyList 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; + } +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index f3ef79f..8787401 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -23,7 +23,8 @@ namespace gclo.ViewModels; /// public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { - private readonly IRepositoryLister _lister; + /// The lister of the place this workspace is on. Quick Sync replaces it when another place is chosen. + private IRepositoryLister _lister; private readonly IGitClient _git; @@ -32,7 +33,36 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// Result of the one-time WSL probe; null until the first path recovery asks for it. private WslAvailability? _wslAvailability; - private readonly IOrganizationLister _orgLister; + /// The organization lister of the place this workspace is on; replaced together with . + private IOrganizationLister _orgLister; + + /// The listers of a place, for a Quick Sync whose place can be chosen; null when the place is fixed. + private readonly Func? _listersOf; + + /// The default token saved for a place, which Quick Sync fills in; null when there is no such source. + private readonly Func? _defaultTokenOf; + + /// Quick Sync's place, as it was last taken from the choice on its card. The listers are the ones of this place. + private ForgeEndpoint _quickSyncPlace = ForgeEndpoint.GitHub; + + /// + /// 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. + /// + private bool _placeIsUsable = true; + + /// + /// 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. + /// + private bool _placeChosen = true; + + private bool _isOnGitea; + + private string _server = ""; private readonly Func, IProgress> _progressFactory; @@ -113,6 +143,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// (), 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. + /// makes this a Quick Sync whose place is chosen on + /// its card (): it gives the listers of a place, and a + /// token is only ever handed to the listers of the place the card is on. + /// gives the default token saved for a place, + /// which such a Quick Sync fills in when it is on that place. /// 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? listersOf = null, + Func? 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"; /// - /// 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 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 says. /// - 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); - /// What the token of this workspace's forge is called: the header of its token box. - public string TokenHeader => ForgeTexts.TokenName(_account?.Forge ?? ForgeKind.GitHub); + /// The kind of forge this workspace is on; for Quick Sync, the one chosen on its card. + private ForgeKind PlaceKind => _account?.Forge ?? (_isOnGitea ? ForgeKind.Gitea : ForgeKind.GitHub); - /// The title of the connect card: where this workspace connects to. - public string ConnectTitle => $"Connect to {PlaceName}"; + /// What the token of this workspace's forge is called: the header of its token box. Before a place is chosen, the plain name. + public string TokenHeader => _placeChosen ? ForgeTexts.TokenName(PlaceKind) : "Access token"; + + /// 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. + public string TokenPlaceholder => _placeChosen && PlaceKind == ForgeKind.GitHub ? "ghp_…" : ""; + + /// + /// 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. + /// + public bool IsPlaceChosen => _placeChosen; + + /// + /// 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. + /// + public bool CanChoosePlace => _listersOf is not null; + + /// + /// The choice on Quick Sync's card: false is GitHub, true is a Gitea server whose + /// address is . 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 (). + /// + 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)); + } + } + + /// + /// 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. + /// + public int PlaceIndex + { + get => !_placeChosen ? -1 : _isOnGitea ? 1 : 0; + set + { + if(value is 0 or 1) + { + IsOnGitea = value == 1; + } + } + } + + /// Whether the card shows the box for the server's address. + public bool AsksForServer => CanChoosePlace && _placeChosen && _isOnGitea; + + /// + /// 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. + /// + 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(); + } + } + + + + /// + /// Told by the shell that the address box is being typed into and holds another + /// text than . 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). + /// + public void ServerIsBeingEdited() + { + if(AsksForServer && _placeIsUsable) + { + _placeIsUsable = false; + SayWhatThePlaceAllows(); + } + } + + + + /// + /// 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. + /// + 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(); + } + + /// What is wrong with ; empty when it can be used, and while none was entered yet. + [ObservableProperty] + public partial string ServerError { get; set; } + + /// + /// 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. + /// + [ObservableProperty] + public partial bool SaveAsAccountOffered { get; set; } + + /// + /// 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. + /// + public Action? Connected { get; set; } + + /// The title of the connect card: where this workspace connects to; just "Connect" before a place is chosen. + public string ConnectTitle => _placeChosen ? $"Connect to {PlaceName}" : "Connect"; /// The page a token for this workspace is made on; null when the account's place is not known. public Uri? TokenPage => ForgeTexts.TokenPage(Place); - /// The place this workspace connects to; null for an account whose stored forge and server cannot be used. + /// + /// 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. + /// private ForgeEndpoint? Place - => _account is null ? ForgeEndpoint.GitHub + => _account is null ? (_placeIsUsable ? _quickSyncPlace : null) : _account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null; + + + /// + /// 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. + /// + private void TakeThePlace(Func 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(); + } + + + + /// + /// 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. + /// + 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(); + } + + + + /// + /// 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. + /// + public void FillInTheDefaultToken() + { + if(_account is null && _placeIsUsable && Token.Length == 0 && _defaultTokenOf?.Invoke(_quickSyncPlace) is { Length: > 0 } saved) + { + Token = saved; + } + } + + + + /// + /// 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 (), and nothing is loaded until + /// the user asks. Nothing happens for an account, or for a Quick Sync whose + /// place cannot be chosen. + /// + 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; + } + } + + + + /// + /// 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. + /// + public AccountWizardSeed? ToAccountSeed() + => _placeIsUsable ? new AccountWizardSeed(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace) : null; + /// /// True for an account workspace whose token is the default token from Settings /// (): the pane tags it, and Sync All names @@ -410,7 +760,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// /// 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. /// [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; + + + + /// + /// 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). + /// + [RelayCommand(CanExecute = nameof(CanFindOrganizations))] + private Task FindOrganizationsAsync() => RefreshOrganizationsAsync(asked: true); + + + /// /// 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 - /// Debounced: each token edit cancels the previous lookup. - private async Task RefreshOrganizationsAsync() + /// + /// Looks the organizations of the token up; each token edit cancels the previous + /// lookup. 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. + /// + 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 orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); + IReadOnlyList 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; } diff --git a/gclo/ConnectionFormControl.xaml b/gclo/ConnectionFormControl.xaml index 7c90afd..6a14f66 100644 --- a/gclo/ConnectionFormControl.xaml +++ b/gclo/ConnectionFormControl.xaml @@ -8,19 +8,73 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" IsTabStop="False" - Loaded="OnLoaded"> + Loaded="OnLoaded" + Unloaded="OnUnloaded"> - + + + + + + + + + + + + + - + 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 diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index 5a10252..8a9b5cf 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -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. --> - - -