feat(app): the Add account wizard asks where the repositories are, and can add an account on a Gitea server #207

Merged
james.maes merged 3 commits from feat/206-gitea-wizard into main 2026-11-11 22:24:40 +00:00
18 changed files with 1273 additions and 74 deletions

No files matched your search

+4 -4
View File
@@ -17,7 +17,7 @@
| **Standards** | Conforms to [KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards) `main@855cf44` (pre-1.0 draft); conformance tracked in [#62](https://git.kof22.com/KofTwentyTwo/gclo/issues/62) |
| **Latest release** | [Releases](https://git.kof22.com/KofTwentyTwo/gclo/releases/latest) |
**gclo** (**G**it **C**lone **L**arge **O**rganizations) mirrors every repository of a GitHub organization or user account into a local folder in one pass: it clones the repositories that are missing and fast-forwards the ones that already exist, with live per-repo progress and bounded parallelism. It ships as a Windows 11 desktop app (WinUI 3) and a scriptable CLI built on the same engine.
**gclo** (**G**it **C**lone **L**arge **O**rganizations) mirrors every repository of a GitHub organization or user account, or of one on a Gitea server, into a local folder in one pass: it clones the repositories that are missing and fast-forwards the ones that already exist, with live per-repo progress and bounded parallelism. It ships as a Windows 11 desktop app (WinUI 3) and a scriptable CLI built on the same engine.
<p align="center">
<img src="docs/images/gclo-sync.png" width="860" alt="gclo mid-sync: overall progress bar, an active-repo strip, and per-repository status with a Cloning row at 15%" />
@@ -124,10 +124,10 @@ 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, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). 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 — 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.
<p align="center">
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name and optional description" />
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name, optional description, and where the repositories are, with a Gitea server chosen and its address entered" />
</p>
## Using the CLI
@@ -199,7 +199,7 @@ Get-Secret -Name GiteaToken -AsPlainText | gclo accounts add --name platform --o
gclo sync --account platform
```
An account's forge and server are fixed once it is added (its token was saved for that place); the app's wizard adds GitHub accounts, a Gitea account is added from the command line for now.
An account's forge and server are fixed once it is added (its token was saved for that place). The app adds such an account as well: **Add account** asks where the repositories are on its first step.
Full reference — all options, output streams, Ctrl+C behavior, and more examples: [docs/CLI.md](docs/CLI.md).
+24 -4
View File
@@ -122,6 +122,26 @@ were the two lister implementations and the failure type they throw.
forge or server cannot be used (a file edited by hand) gets listers that ask no
server anything and fail with the reason.
### The desktop app's wizard (#206)
- **The place is asked first, on step 1, for a new account only.** GitHub is the
default; a Gitea server needs its address, which is checked by the same rule as
on the command line (`ForgeEndpoint.TryCreate`). Step 2 names the address as it
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.
- **The wizard has the lister of one place at a time.** The shell gives it a
lister per place; step 1 takes the lister of the place that was chosen, and step
2 has no other to send the token to. A wizard that was given one lister (an
edit, a saved Quick Sync connection) offers no choice.
- **What was entered for one place is not carried to another.** When step 1 is
left with another place than before, the typed token, the organization and the
list of organizations are emptied, and the default token (a GitHub token) is
neither offered nor preselected for a Gitea server.
- **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.
## Alternatives considered
- **A Gitea client package.** One more dependency to track and to trust with the
@@ -151,8 +171,8 @@ were the two lister implementations and the failure type they throw.
by hand, as on GitHub.
- The git client uses the same credential form as for GitHub (a fixed user name,
the token as the password). Gitea accepts a token as the password with any user
name; this is to be confirmed against a live server before accounts can use a
Gitea server (#201, step 2).
name; this is still to be confirmed against a live server, before the release
that brings Gitea support (#201).
- The engine's public surface grows (`ForgeKind`, `ForgeAccessException`,
`ForgeAccessKind`, `GiteaServerUrl`, the two listers), and the base type of
`GitHubAccessException` changes: a minor version for the package. Code that
@@ -160,5 +180,5 @@ were the two lister implementations and the failure type they throw.
up by name through reflection now finds two properties.
- A server answers a renamed organization or account with a redirect, which gclo
does not follow: the message says to use the new name.
- Accounts, the command line and the desktop app follow in their own changes
(#201). Until then the app and the CLI behave as before.
- Accounts, the command line and the desktop app came in their own changes
(#204, #206; the sections above).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 48 KiB

+3 -2
View File
@@ -27,7 +27,7 @@ about the user's accounts or repositories. It sends nothing anywhere else.
| gclo app / CLI | `gclo.exe` (WinUI 3) and the CLI `gclo.exe`, same engine | Trusted; runs as the user |
| GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT |
| Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) |
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). Used by accounts and the command line (#204); the desktop app's wizard cannot add such an account yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). Used by accounts and the command line (#204), and by the desktop app's Add account wizard (#206) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
| Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account |
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
@@ -100,7 +100,7 @@ flowchart LR
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. | 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) |
| 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) |
## 7. Accepted risks
@@ -134,3 +134,4 @@ flowchart LR
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T4: a proxy named in a folder's own git configuration, and its own `http.followRedirects`, are refused like a URL rewrite, so the folder does not choose the server a fetch is made through or sent on to (the token was not exposed by the proxy: TLS is end to end). T3: a repository whose name is git's own folder name, in any spelling Windows opens as that folder, fails its row in the sync on every platform, not only on Windows, and is refused by the WSL clone (#184). |
| 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. |
@@ -0,0 +1,716 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Where a new account lives, as the wizard's first step asks it (#206): GitHub or a
/// Gitea server and its address. The rule these tests are built around is that a
/// token goes to one place only, the one that was chosen, and that what was entered
/// for one place is not carried to another. Each lister here records the tokens it
/// was given under the place it is the lister of.
/// </summary>
public sealed class AccountWizardPlaceTests : IDisposable
{
private const string Server = "https://git.example.test/";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly AccountsStore _store;
private readonly AppSettings _defaults = new()
{
DefaultTargetFolder = @"C:\repos-default",
DefaultMaxConcurrency = 6,
};
/// <summary>Every token a lister was given, with the name of the place the lister is for.</summary>
private readonly ConcurrentQueue<(string Place, string Token)> _sent = new();
public AccountWizardPlaceTests()
{
_store = new AccountsStore(_vault, _root);
}
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
/// <summary>The lister of a place: it records what it is given and lists one organization named after the place.</summary>
private FakeOrganizationLister ListerOf(ForgeEndpoint place) => new()
{
Handler = (token, _) =>
{
_sent.Enqueue((place.DisplayName, token));
return Task.FromResult<IReadOnlyList<string>>([$"org-at-{place.DisplayName}"]);
},
};
private AccountWizardViewModel ChoosingWizard(string? defaultToken = null)
=> new(_store, ListerOf, _defaults, defaultToken: () => defaultToken);
private static Account GiteaAccount(string? server = Server) => new()
{
Id = Guid.NewGuid(),
Name = "Forge",
Organization = "platform",
TargetRoot = @"C:\repos",
Forge = ForgeKind.Gitea,
Server = server,
};
private static async Task AdvanceToStepAsync(AccountWizardViewModel wizard, int step)
{
while(wizard.Step < step)
{
int before = wizard.Step;
Assert.True(await wizard.TryAdvanceAsync(), $"expected to advance past step {before}");
}
Assert.Equal(step, wizard.Step);
}
// ---------------------------------------------------------------- the choice
[Fact]
public void AWizardThatMayChoose_StartsOnGitHub_AndAsksForNoServer()
{
AccountWizardViewModel wizard = ChoosingWizard();
Assert.True(wizard.CanChoosePlace);
Assert.False(wizard.IsOnGitea);
Assert.Equal(0, wizard.PlaceIndex);
Assert.False(wizard.AsksForServer);
Assert.Equal("", wizard.Server);
Assert.Equal("", wizard.ServerError);
Assert.Equal("", wizard.PlaceNote); // step 1 asks, so there is nothing to tell
Assert.Equal("GitHub", wizard.PlaceName);
Assert.Equal("Personal access token", wizard.TokenHeader);
Assert.Equal("ghp_…", wizard.TokenPlaceholder);
Assert.Equal("Stored in Windows Credential Manager, never in a file; needs repository read access", wizard.TokenAdvice);
Assert.Equal(new Uri("https://github.com/settings/tokens"), wizard.TokenPage);
Assert.Equal("Next checks the token with GitHub and lists the organizations it can see.", wizard.TokenStepNote);
// Seeded from the defaults like any new account.
Assert.Equal(@"C:\repos-default", wizard.TargetRoot);
Assert.Equal(6, wizard.MaxConcurrency);
Assert.False(wizard.IsEditing);
}
[Fact]
public void PlaceIndex_IsTheChoiceAsARadioGroupReadsIt_AndNoSelectionChangesNothing()
{
AccountWizardViewModel wizard = ChoosingWizard();
var changed = new List<string?>();
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
wizard.PlaceIndex = 1;
Assert.True(wizard.IsOnGitea);
Assert.True(wizard.AsksForServer);
Assert.Equal(1, wizard.PlaceIndex);
Assert.Contains(nameof(AccountWizardViewModel.PlaceIndex), changed, StringComparer.Ordinal);
Assert.Contains(nameof(AccountWizardViewModel.AsksForServer), changed, StringComparer.Ordinal);
wizard.PlaceIndex = -1; // a radio group that has no selection for a moment
Assert.True(wizard.IsOnGitea);
wizard.PlaceIndex = 0;
Assert.False(wizard.IsOnGitea);
Assert.False(wizard.AsksForServer);
}
// ---------------------------------------------------------------- where the token goes
[Fact]
public async Task AnAccountOnAGiteaServer_HasItsTokenCheckedThere_AndIsSavedThere()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = " git.example.test "; // as a person types it
await AdvanceToStepAsync(wizard, 2);
// The address is shown as it was normalized: it is the one the token goes to.
Assert.Equal(Server, wizard.Server);
Assert.Equal("", wizard.ServerError);
Assert.Equal("git.example.test", wizard.PlaceName);
Assert.Equal("Access token", wizard.TokenHeader);
Assert.Equal("", wizard.TokenPlaceholder);
Assert.Equal(
"Stored in Windows Credential Manager, never in a file; needs read access to repositories, organizations and the user",
wizard.TokenAdvice);
Assert.Equal(new Uri("https://git.example.test/user/settings/applications"), wizard.TokenPage);
// The whole address, not the host alone: it is what the token is about to be sent to.
Assert.Equal("Next checks the token with https://git.example.test/ and lists the organizations it can see.", wizard.TokenStepNote);
Assert.Empty(_sent); // nothing was sent anywhere to get here
wizard.Token = "gitea_token";
await AdvanceToStepAsync(wizard, 3);
Assert.Equal([("git.example.test", "gitea_token")], _sent);
Assert.Equal(["org-at-git.example.test"], wizard.Organizations);
wizard.Organization = "platform";
await AdvanceToStepAsync(wizard, 4);
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(TokenSource.Own, saved.TokenSource);
Assert.Equal("gitea_token", _vault.TryRetrieve(saved.TokenEntryId));
Assert.Null(_vault.TryRetrieve(saved.Id)); // not where a version that knows only GitHub looks
}
[Fact]
public async Task AnAccountLeftOnGitHub_IsAGitHubAccount_AsBefore()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Work";
wizard.Server = "git.example.test"; // typed, then GitHub chosen after all: not used
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "acme";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal([("GitHub", "ghp_token")], _sent);
Account saved = Assert.Single(new AccountsStore(_vault, _root).GetAll());
Assert.Equal(ForgeKind.GitHub, saved.Forge);
Assert.Null(saved.Server);
Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id));
}
[Theory]
[InlineData("", "Enter the address of the server, like https://git.example.com.")]
[InlineData(" ", "Enter the address of the server, like https://git.example.com.")]
[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.")]
[InlineData("https:/git.example.test", "The server address cannot be used: it is not an address (a server's address looks like https://git.example.com).")]
public async Task AServerAddressThatCannotBeUsed_KeepsTheWizardOnStepOne_AndNothingIsSent(string typed, string said)
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = typed;
wizard.Token = "gitea_token";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.Equal(said, wizard.ServerError);
Assert.DoesNotContain("s3cret", wizard.ServerError, StringComparison.Ordinal); // a refused address is not repeated
Assert.Equal("", wizard.NameError);
Assert.Equal(typed, wizard.Server); // left as it was typed, to be corrected
Assert.Equal("GitHub", wizard.PlaceName); // no place was taken
Assert.Empty(_sent);
// Corrected, the wizard goes on and the message goes away.
wizard.Server = "git.example.test";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("", wizard.ServerError);
}
[Fact]
public async Task TheMessageAboutTheServer_GoesAway_WhenTheOtherPlaceIsChosen()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Work";
wizard.IsOnGitea = true;
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("Enter the address of the server, like https://git.example.com.", wizard.ServerError);
wizard.IsOnGitea = false;
Assert.Equal("", wizard.ServerError);
Assert.True(await wizard.TryAdvanceAsync());
}
[Fact]
public async Task ABlankName_IsSaidFirst_AndTheServerIsNotLookedAt()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.IsOnGitea = true;
wizard.Server = "http://git.example.test";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("Enter a name for this account.", wizard.NameError);
Assert.Equal("", wizard.ServerError);
}
// ---------------------------------------------------------------- nothing is carried to another place
[Fact]
public async Task ATokenTypedForOnePlace_IsNotKept_WhenAnotherPlaceIsChosen()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Work";
wizard.Token = "ghp_typed_for_github";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "org-at-GitHub";
var changed = new List<string?>();
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
// Back to step 1, and the account is to be on a Gitea server after all.
wizard.GoBack();
wizard.GoBack();
wizard.IsOnGitea = true;
wizard.Server = Server;
await AdvanceToStepAsync(wizard, 2);
Assert.Equal("", wizard.Token);
Assert.Contains(nameof(AccountWizardViewModel.Token), changed, StringComparer.Ordinal); // the dialog empties its box on this
Assert.Equal("", wizard.Organization); // an organization of the other place
Assert.Empty(wizard.Organizations);
// With nothing typed for the new place, nothing is sent to it.
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("Enter a token to continue.", wizard.TokenError);
Assert.Equal([("GitHub", "ghp_typed_for_github")], _sent);
// The same holds from one server to another.
wizard.Token = "token_for_the_first_server";
await AdvanceToStepAsync(wizard, 3);
wizard.GoBack();
wizard.GoBack();
wizard.Server = "https://other.example.test";
await AdvanceToStepAsync(wizard, 2);
Assert.Equal("", wizard.Token);
Assert.Equal("other.example.test", wizard.PlaceName);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(
[("GitHub", "ghp_typed_for_github"), ("git.example.test", "token_for_the_first_server")],
_sent);
}
[Fact]
public async Task GoingBackWithoutChoosingAnotherPlace_KeepsWhatWasEntered()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = "git.example.test";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "gitea_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "platform";
wizard.GoBack();
wizard.GoBack();
wizard.Name = "Forge, renamed";
wizard.Server = "https://GIT.example.test"; // the same server, spelled another way
await AdvanceToStepAsync(wizard, 2);
Assert.Equal("gitea_token", wizard.Token);
Assert.Equal("platform", wizard.Organization);
Assert.Equal(Server, wizard.Server);
}
[Fact]
public async Task TwoServersOnOneHost_AreToldApart_WhereTheTokenIsAskedFor()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = "git.example.test/team-a";
await AdvanceToStepAsync(wizard, 2);
Assert.Equal("Next checks the token with https://git.example.test/team-a/ and lists the organizations it can see.", wizard.TokenStepNote);
Assert.Equal(new Uri("https://git.example.test/team-a/user/settings/applications"), wizard.TokenPage);
}
[Fact]
public async Task WhatStepTwoSays_FollowsThePlace_WhenItIsChosenAgain()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.Token = "";
await AdvanceToStepAsync(wizard, 2);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("Enter a token to continue.", wizard.TokenError); // said about the other place
var changed = new List<string?>();
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
wizard.GoBack();
wizard.IsOnGitea = true;
wizard.Server = Server;
await AdvanceToStepAsync(wizard, 2);
// Step 2 is bound to these: without the notifications it would go on naming GitHub and linking GitHub's page.
Assert.All(
(string[])
[
nameof(AccountWizardViewModel.PlaceName), nameof(AccountWizardViewModel.TokenStepNote), nameof(AccountWizardViewModel.TokenPage),
nameof(AccountWizardViewModel.TokenHeader), nameof(AccountWizardViewModel.TokenPlaceholder), nameof(AccountWizardViewModel.TokenAdvice),
],
name => Assert.Contains(name, changed, StringComparer.Ordinal));
Assert.Equal("", wizard.TokenError);
}
[Fact]
public async Task TheAccountIsSavedAtThePlaceStepOneAccepted_WhateverTheChoiceIsSetToAfterwards()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = Server;
await AdvanceToStepAsync(wizard, 2);
// As a binding could set them while step 1 is not on screen: no place is taken without step 1.
wizard.IsOnGitea = false;
wizard.Server = "https://elsewhere.example.test";
wizard.Token = "gitea_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "platform";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal([("git.example.test", "gitea_token")], _sent);
Account saved = Assert.Single(new AccountsStore(_vault, _root).GetAll());
Assert.Equal(ForgeKind.Gitea, saved.Forge);
Assert.Equal(Server, saved.Server);
}
[Fact]
public async Task APlaceThatNoListerCanBeMadeFor_IsNotTaken()
{
var wizard = new AccountWizardViewModel(
_store,
place => place.Server is null ? ListerOf(place) : throw new InvalidOperationException("no lister for that place"),
_defaults);
wizard.Name = "Forge";
wizard.Token = "ghp_typed_for_github";
wizard.IsOnGitea = true;
wizard.Server = Server;
await Assert.ThrowsAsync<InvalidOperationException>(wizard.TryAdvanceAsync);
// The wizard is where it was: on GitHub, with GitHub's lister and the token typed for GitHub.
Assert.Equal(1, wizard.Step);
Assert.Equal("GitHub", wizard.PlaceName);
Assert.Equal("ghp_typed_for_github", wizard.Token);
wizard.IsOnGitea = false;
await AdvanceToStepAsync(wizard, 3);
Assert.Equal([("GitHub", "ghp_typed_for_github")], _sent);
}
[Fact]
public async Task WhileATokenIsBeingChecked_TheWizardDoesNotMove()
{
var answer = new TaskCompletionSource<IReadOnlyList<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
var wizard = new AccountWizardViewModel(
_store,
place => new FakeOrganizationLister
{
Handler = (token, _) =>
{
_sent.Enqueue((place.DisplayName, token));
return answer.Task;
},
},
_defaults);
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 2);
Task<bool> checking = wizard.TryAdvanceAsync();
Assert.True(wizard.IsValidatingToken);
// Neither back, nor on: the answer that is on its way is for this place and this token.
wizard.GoBack();
Assert.Equal(2, wizard.Step);
Task<bool> again = wizard.TryAdvanceAsync();
Assert.True(again.IsCompleted, "a second Next started another check instead of being refused");
Assert.False(await again);
Assert.Equal(2, wizard.Step);
Assert.Single(_sent);
answer.SetResult(["me", "acme"]);
Assert.True(await checking);
Assert.Equal(3, wizard.Step);
Assert.Equal(["me", "acme"], wizard.Organizations);
wizard.GoBack(); // and it moves again
Assert.Equal(2, wizard.Step);
}
[Fact]
public async Task TheMessageAboutTheServer_GoesAway_WhenTheAddressIsCorrected()
{
AccountWizardViewModel wizard = ChoosingWizard();
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = "http://git.example.test";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("The server address cannot be used: it has to begin with https://.", wizard.ServerError);
wizard.Server = "https://git.example.test";
Assert.Equal("", wizard.ServerError);
}
// ---------------------------------------------------------------- the default token is a GitHub token
[Fact]
public async Task TheDefaultToken_IsOnOfferForGitHub_AndNeverSentToAGiteaServer()
{
AccountWizardViewModel wizard = ChoosingWizard(defaultToken: "ghp_default");
Assert.True(wizard.OffersTokenChoice);
Assert.True(wizard.UseDefaultToken);
var changed = new List<string?>();
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
wizard.Name = "Forge";
wizard.IsOnGitea = true;
wizard.Server = Server;
await AdvanceToStepAsync(wizard, 2);
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
Assert.Contains(nameof(AccountWizardViewModel.OffersTokenChoice), changed, StringComparer.Ordinal);
Assert.False(await wizard.TryAdvanceAsync()); // no token of its own was typed
Assert.Equal("Enter a token to continue.", wizard.TokenError);
Assert.Empty(_sent);
// The choice is not shown here. Set all the same, it is refused: nothing is sent.
wizard.TokenChoiceIndex = 0;
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
Assert.Empty(_sent);
// Back on GitHub, it is on offer again, and preselected as for any new account.
wizard.GoBack();
wizard.IsOnGitea = false;
await AdvanceToStepAsync(wizard, 3);
Assert.True(wizard.OffersTokenChoice);
Assert.True(wizard.UseDefaultToken);
Assert.Equal("", wizard.TokenError);
Assert.Equal([("GitHub", "ghp_default")], _sent);
}
// ---------------------------------------------------------------- when there is no choice
[Fact]
public async Task AWizardThatWasGivenOneLister_OffersNoChoice_AndItsAccountIsOnGitHub()
{
var onlyLister = new FakeOrganizationLister();
var wizard = new AccountWizardViewModel(_store, onlyLister, _defaults);
Assert.False(wizard.CanChoosePlace);
Assert.Equal("This account is on GitHub.", wizard.PlaceNote);
// Whatever is set on it, it asks for no server and stays where its lister is.
wizard.IsOnGitea = true;
wizard.Server = Server;
Assert.False(wizard.AsksForServer);
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "acme";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal(1, onlyLister.Calls);
Account saved = Assert.Single(new AccountsStore(_vault, _root).GetAll());
Assert.Equal(ForgeKind.GitHub, saved.Forge);
Assert.Null(saved.Server);
}
[Fact]
public void AQuickSyncConnectionSavedAsAnAccount_IsOnGitHub_AndOffersNoChoice()
{
var seed = new AccountWizardSeed("ghp_token", "acme", @"C:\repos", CreateOrgSubfolder: false, MaxConcurrency: 4);
var wizard = new AccountWizardViewModel(_store, new FakeOrganizationLister(), seed);
Assert.False(wizard.CanChoosePlace);
Assert.Equal("This account is on GitHub.", wizard.PlaceNote);
Assert.Equal(new Uri("https://github.com/settings/tokens"), wizard.TokenPage);
}
[Fact]
public void EditingAnAccountOnAGiteaServer_SaysWhereItIs_AndThatItStaysThere()
{
Account account = GiteaAccount();
var wizard = new AccountWizardViewModel(_store, new FakeOrganizationLister(), _defaults, account, () => "gitea_token");
Assert.False(wizard.CanChoosePlace);
Assert.False(wizard.AsksForServer);
Assert.Equal("", wizard.Server); // what is stored is not put into a box
Assert.Equal(
"This account is on git.example.test. That cannot be changed: its token was saved for that place.",
wizard.PlaceNote);
Assert.Equal("Next checks the token with https://git.example.test/ and lists the organizations it can see.", wizard.TokenStepNote);
Assert.Equal("git.example.test", wizard.PlaceName);
Assert.Equal("Access token", wizard.TokenHeader);
Assert.Equal("Leave empty to keep the stored token", wizard.TokenPlaceholder);
Assert.Equal(new Uri("https://git.example.test/user/settings/applications"), wizard.TokenPage);
}
[Fact]
public void EditingAGitHubAccount_SaysItIsOnGitHub()
{
Account account = GiteaAccount(server: null) with { Forge = ForgeKind.GitHub, TokenSource = TokenSource.Default };
var wizard = new AccountWizardViewModel(_store, new FakeOrganizationLister(), _defaults, account, () => null);
Assert.Equal("", wizard.Server);
Assert.Equal("This account is on GitHub. That cannot be changed: its token was saved for that place.", wizard.PlaceNote);
Assert.Equal("ghp_…", wizard.TokenPlaceholder); // it has no stored token of its own to keep
}
[Fact]
public void EditingAnAccountWhoseStoredAddressCannotBeUsed_SaysSo_AndPointsAtNoPage()
{
Account account = GiteaAccount(server: "http://git.example.test"); // as a file edited by hand could say
var wizard = new AccountWizardViewModel(_store, new FakeOrganizationLister(), _defaults, account, () => "gitea_token");
Assert.Equal(
"This account is on Gitea, at an address that cannot be used. Delete the account and add it again.",
wizard.PlaceNote);
Assert.Equal("Gitea, at an address that cannot be used", wizard.PlaceName);
Assert.Null(wizard.TokenPage);
Assert.Equal("Access token", wizard.TokenHeader);
}
[Fact]
public void EditingAnAccountOnAForgeThisVersionDoesNotKnow_SaysWhichVersionToUse()
{
Account account = GiteaAccount() with { ForgeName = "GitLab" }; // as a later version of gclo could have added it
var wizard = new AccountWizardViewModel(_store, new FakeOrganizationLister(), _defaults, account, () => "a_token");
Assert.Equal(
"This account is on a forge this version of gclo does not know. Use the version of gclo that added it.",
wizard.PlaceNote);
Assert.Null(wizard.TokenPage);
Assert.False(wizard.OffersTokenChoice);
}
// ---------------------------------------------------------------- arguments
[Fact]
public void AWizardThatMayChoose_NeedsItsListersAndItsDefaults()
{
Assert.Throws<ArgumentNullException>(
() => new AccountWizardViewModel(_store, (Func<ForgeEndpoint, IOrganizationLister>)null!, _defaults));
Assert.Throws<ArgumentNullException>(() => new AccountWizardViewModel(_store, ListerOf, null!));
}
// ---------------------------------------------------------------- an account's page
[Fact]
public void AWorkspace_NamesThePlaceItConnectsTo()
{
using var quickSync = new WorkspaceViewModel(new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister());
using var onGitea = new WorkspaceViewModel(
new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister(), account: GiteaAccount(), tokenVault: _vault);
using var onGitHub = new WorkspaceViewModel(
new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister(),
account: GiteaAccount(server: null) with { Forge = ForgeKind.GitHub }, tokenVault: _vault);
using var unusable = new WorkspaceViewModel(
new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister(),
account: GiteaAccount(server: "http://git.example.test"), tokenVault: _vault);
Assert.Equal("Connect to GitHub", quickSync.ConnectTitle);
Assert.Equal(new Uri("https://github.com/settings/tokens"), quickSync.TokenPage);
Assert.Equal("Personal access token", quickSync.TokenHeader);
Assert.Equal("Connect to git.example.test", onGitea.ConnectTitle);
Assert.Equal(new Uri("https://git.example.test/user/settings/applications"), onGitea.TokenPage);
Assert.Equal("Access token", onGitea.TokenHeader);
Assert.Equal("Connect to GitHub", onGitHub.ConnectTitle);
Assert.Equal(new Uri("https://github.com/settings/tokens"), onGitHub.TokenPage);
// What is stored cannot be used: the address is not repeated, and there is no page.
Assert.Equal("Connect to Gitea, at an address that cannot be used", unusable.ConnectTitle);
Assert.Null(unusable.TokenPage);
}
}
+48 -1
View File
@@ -241,7 +241,54 @@ public sealed partial class SmokeTests : UiTestBase
// (9) Resizing below the minimum window size is intentionally not covered:
// (9) A new account on a Gitea server (#206). Step 1 asks for the server's
// address only once that place is chosen; step 2 names the server the token
// is checked with; and step 3 lists what that server listed, which here is
// the fixture's organization for a Gitea server: the fixture token is
// answered offline for the place the wizard asked, so nothing leaves the
// machine. The wizard is canceled on step 3, so nothing is saved either.
[Fact]
public void AddAccountWizard_OnAGiteaServer_ChecksTheTokenWithThatServer()
{
Session.InvokeNavItem("NavAddAccount");
try
{
Session.EnterText(Session.WaitForElement("WizardNameBox"), "Forge");
Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardServerBox"))); // GitHub is the default: no address is asked
Session.WaitForElement("WizardPlaceGitea").AsRadioButton().IsChecked = true;
Session.EnterText(Session.WaitForElement("WizardServerBox"), "git.example.test");
Session.ClickButton("Next");
Session.WaitForText("Step 2 of 4: Access token");
Session.WaitForText("Next checks the token with https://git.example.test/ and lists the organizations it can see.");
Session.EnterText(Session.WaitForElement("WizardTokenBox"), "gclo-uitest-fixture-token");
Session.ClickButton("Next");
Session.WaitForText("Step 3 of 4: Organization", TimeSpan.FromSeconds(20));
ComboBox orgBox = Session.WaitForElement("WizardOrgBox").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);
Session.ClickButton("Cancel");
Session.WaitForElementGone("WizardOrgBox");
}
finally
{
Session.TryClickButton("Cancel"); // no-op when the happy path closed it
}
}
// (10) 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.
+245 -19
View File
@@ -12,17 +12,37 @@ namespace gclo.ViewModels;
/// <summary>
/// Drives the four-step add/edit account wizard: identity (name + description), token
/// (validated by listing the organizations it can see), organization, and destination.
/// <see cref="TryAdvanceAsync"/> gates each step; <see cref="SaveAsync"/> persists the
/// result through the <see cref="AccountsStore"/>, writing the token to the vault only
/// when the user actually changed it (or always, for a new account).
/// Drives the four-step add/edit account wizard: identity (name + description, and
/// for a new account the place it lives: GitHub or a Gitea server), token (validated
/// by listing the organizations it can see at that place), organization, and
/// destination. <see cref="TryAdvanceAsync"/> gates each step; <see cref="SaveAsync"/>
/// persists the result through the <see cref="AccountsStore"/>, writing the token to
/// the vault only when the user actually changed it (or always, for a new account).
/// </summary>
/// <remarks>
/// A token is sent to one place only: the one the account is on. A wizard that was
/// given one lister is for the place of that lister (the account being edited, or
/// GitHub) and offers no choice; one that was given a lister per place lets step 1
/// choose, and forgets the token when the place changes after it was typed.
/// </remarks>
public sealed partial class AccountWizardViewModel : ObservableObject
{
private readonly AccountsStore _store;
private readonly IOrganizationLister _orgLister;
/// <summary>
/// The lister of the place the account is on: the one place a token of this
/// wizard is sent to. Replaced when step 1 chooses another place.
/// </summary>
private IOrganizationLister _orgLister;
/// <summary>The lister of a place, when step 1 may choose the place; null when the place is fixed.</summary>
private readonly Func<ForgeEndpoint, IOrganizationLister>? _listerOf;
/// <summary>The place of a new account, as step 1 last accepted it.</summary>
private ForgeEndpoint _chosen = ForgeEndpoint.GitHub;
/// <summary>The place of the account being edited; null for a new account, and when what is stored cannot be used.</summary>
private readonly ForgeEndpoint? _existingPlace;
private readonly IActivityLog _log;
@@ -61,7 +81,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Func<string?>? storedToken = null,
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, existing, storedToken, defaultToken, log)
: this(store, orgLister, listerOf: null, existing, storedToken, defaultToken, log)
{
ArgumentNullException.ThrowIfNull(defaults);
@@ -87,6 +107,27 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// A wizard for a NEW account whose place is chosen on step 1: GitHub, or a Gitea
/// server and its address. <paramref name="listerOf"/> gives the organization
/// lister of a place; the token of step 2 goes to the lister of the chosen place
/// and to no other.
/// </summary>
public AccountWizardViewModel(
AccountsStore store,
Func<ForgeEndpoint, IOrganizationLister> listerOf,
AppSettings defaults,
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, ListerOfGitHub(listerOf), listerOf, existing: null, storedToken: null, defaultToken, log)
{
ArgumentNullException.ThrowIfNull(defaults);
TargetRoot = defaults.DefaultTargetFolder;
MaxConcurrency = defaults.DefaultMaxConcurrency;
}
/// <summary>
/// A wizard for a NEW account seeded from a working Quick Sync connection
/// (<paramref name="seed"/>): token, organization, folder, subfolder preference,
@@ -97,7 +138,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
public AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, existing: null, storedToken: null, defaultToken, log)
: this(store, orgLister, listerOf: null, existing: null, storedToken: null, defaultToken, log)
{
ArgumentNullException.ThrowIfNull(seed);
Name = "";
@@ -116,16 +157,27 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>A new account starts on GitHub; the lister of that place is the one the wizard begins with.</summary>
private static IOrganizationLister ListerOfGitHub(Func<ForgeEndpoint, IOrganizationLister> listerOf)
{
ArgumentNullException.ThrowIfNull(listerOf);
return listerOf(ForgeEndpoint.GitHub);
}
private AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken,
Func<string?>? defaultToken, IActivityLog? log)
AccountsStore store, IOrganizationLister orgLister, Func<ForgeEndpoint, IOrganizationLister>? listerOf,
Account? existing, Func<string?>? storedToken, Func<string?>? defaultToken, IActivityLog? log)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
_store = store;
_orgLister = orgLister;
_listerOf = listerOf;
_log = log ?? new NullActivityLog();
_existing = existing;
_existingPlace = existing is not null && existing.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null;
_storedToken = existing is null ? null : storedToken;
_defaultToken = defaultToken;
// Decided once: the choice is offered only when a default token exists at
@@ -135,6 +187,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Step = 1;
NameError = "";
Server = "";
ServerError = "";
TokenError = "";
OrganizationError = "";
TargetError = "";
@@ -162,13 +216,99 @@ public sealed partial class AccountWizardViewModel : ObservableObject
public partial string Description { get; set; }
/// <summary>
/// GitHub personal access token as typed; validated when leaving step 2. For an
/// edit it starts empty and stays empty unless the user types a replacement —
/// the stored token is never loaded into it.
/// The token as typed; validated when leaving step 2. For an edit it starts
/// empty and stays empty unless the user types a replacement — the stored token
/// is never loaded into it. Emptied when step 1 changes the place: a token is
/// typed for one place.
/// </summary>
[ObservableProperty]
public partial string Token { get; set; }
/// <summary>
/// Whether step 1 asks where the account lives. Only for a new account in a
/// wizard that was given a lister per place; an account being edited stays where
/// it is, and a Quick Sync connection saved as an account is a GitHub connection.
/// </summary>
public bool CanChoosePlace => _listerOf is not null;
/// <summary>Step 1's choice: false is GitHub, true is a Gitea server whose address is <see cref="Server"/>.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PlaceIndex))]
[NotifyPropertyChangedFor(nameof(AsksForServer))]
public partial bool IsOnGitea { get; set; }
/// <summary>
/// <see cref="IsOnGitea"/> as the selected index of a two-item radio group:
/// 0 = GitHub, 1 = a Gitea server. Any other index (a radio group reports -1
/// while it has no selection) changes nothing.
/// </summary>
public int PlaceIndex
{
get => IsOnGitea ? 1 : 0;
set
{
if(value is 0 or 1)
{
IsOnGitea = value == 1;
}
}
}
/// <summary>Whether step 1 shows the box for the server's address.</summary>
public bool AsksForServer => CanChoosePlace && IsOnGitea;
/// <summary>
/// The address of the Gitea server as typed; after step 1 accepted it, as it was
/// normalized, which is the address the token is sent to. Empty for an edit:
/// where the account is, <see cref="PlaceNote"/> says, and what is stored is not
/// put into a box (an address that cannot be used may hold a password).
/// </summary>
[ObservableProperty]
public partial string Server { get; set; }
/// <summary>Step 1's message about the server address; empty when it is acceptable.</summary>
[ObservableProperty]
public partial string ServerError { get; set; }
/// <summary>
/// How the place the token goes to is named: "GitHub", or the server's host. For
/// an account whose stored place cannot be used, what <see cref="Account.DescribePlace"/> says.
/// </summary>
public string PlaceName => _existing is null ? _chosen.DisplayName : _existing.DescribePlace();
/// <summary>
/// Said on step 1 when the place cannot be chosen: where the account is, and for
/// an edit why that stays. Empty when step 1 asks.
/// </summary>
public string PlaceNote
=> CanChoosePlace ? ""
: _existing is null ? $"This account is on {PlaceName}."
: Place is null ? $"This account is on {PlaceName}. {_existing.AdviceWhenUnusable("Delete")}"
: $"This account is on {PlaceName}. That cannot be changed: its token was saved for that place.";
/// <summary>The header of step 2's token box: what the forge calls a token.</summary>
public string TokenHeader => ForgeTexts.TokenName(PlaceKind);
/// <summary>What step 2's empty token box says.</summary>
public string TokenPlaceholder
=> _existing is { UsesDefaultToken: false } ? "Leave empty to keep the stored token"
: PlaceKind == ForgeKind.GitHub ? "ghp_…"
: "";
/// <summary>The tooltip of step 2's token box: where the token is kept and what it needs.</summary>
public string TokenAdvice => $"Stored in Windows Credential Manager, never in a file; {ForgeTexts.TokenNeeds(PlaceKind)}";
/// <summary>The page a token is made on; null when the account's place is not known.</summary>
public Uri? TokenPage => ForgeTexts.TokenPage(Place);
/// <summary>
/// Step 2's note on what Next does, naming the place the token is sent to: for a
/// server its whole address as it was normalized, not the host alone, since two
/// servers can share a host.
/// </summary>
public string TokenStepNote
=> $"Next checks the token with {Place?.Server?.AbsoluteUri ?? PlaceName} and lists the organizations it can see.";
/// <summary>Organization (or user) to sync; picked from <see cref="Organizations"/> or typed.</summary>
[ObservableProperty]
public partial string Organization { get; set; }
@@ -220,7 +360,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// token, and such an account has a token of its own.
/// </remarks>
public bool OffersTokenChoice
=> (_existing?.Forge ?? ForgeKind.GitHub) == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
=> PlaceKind == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
/// <summary>
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
@@ -300,6 +440,24 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>The kind of forge the account is on: the stored one for an edit, the chosen one for a new account.</summary>
private ForgeKind PlaceKind => _existing?.Forge ?? _chosen.Kind;
/// <summary>The place the account is on; null for an edit whose stored forge and server cannot be used.</summary>
private ForgeEndpoint? Place => _existing is null ? _chosen : _existingPlace;
partial void OnIsOnGiteaChanged(bool value) => ServerError = "";
partial void OnServerChanged(string value) => ServerError = "";
partial void OnMaxConcurrencyChanged(int value)
{
int clamped = Math.Clamp(value, AppSettings.MinConcurrency, AppSettings.MaxConcurrency);
@@ -320,6 +478,16 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// </summary>
public async Task<bool> TryAdvanceAsync()
{
if(IsValidatingToken)
{
///////////////////////////////////////////////////////////////////////////
// A token is being checked with the place the wizard is on. Until the //
// answer is there the wizard stays as it is (GoBack too): the answer is //
// then the answer for the place and the token the wizard still holds. //
///////////////////////////////////////////////////////////////////////////
return false;
}
switch(Step)
{
case 1:
@@ -337,6 +505,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
return false;
}
NameError = "";
if(_listerOf is not null && !TryChoosePlace(_listerOf))
{
return false;
}
Step = 2;
return true;
}
@@ -347,7 +519,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// An edit with the box left empty validates the STORED token, fetched
// here and used for this one call only.
string candidate;
if(UseDefaultToken && (_existing?.Forge ?? ForgeKind.GitHub) != ForgeKind.GitHub)
if(UseDefaultToken && PlaceKind != ForgeKind.GitHub)
{
///////////////////////////////////////////////////////////////////
// The choice is not shown for such an account. Whatever set it, //
@@ -450,10 +622,64 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>Returns to the previous step; a no-op on the first step.</summary>
/// <summary>
/// Step 1, for a new account: takes the place that was chosen, or says in
/// <see cref="ServerError"/> why the server address cannot be used. The address
/// is checked by the rule the command line uses (<see cref="ForgeEndpoint.TryCreate"/>),
/// and shown afterwards as it was normalized.
/// </summary>
private bool TryChoosePlace(Func<ForgeEndpoint, IOrganizationLister> listerOf)
{
if(IsOnGitea && string.IsNullOrWhiteSpace(Server))
{
ServerError = "Enter the address of the server, like https://git.example.com.";
return false;
}
if(!ForgeEndpoint.TryCreate(IsOnGitea ? ForgeKind.Gitea : ForgeKind.GitHub, IsOnGitea ? Server : null, out ForgeEndpoint? place, out string? reason))
{
ServerError = char.ToUpperInvariant(reason[0]) + reason[1..] + ".";
return false;
}
ServerError = "";
if(!place.Equals(_chosen))
{
//////////////////////////////////////////////////////////////////////////////
// Another place than the one the wizard was on. What was entered for the //
// other place is not carried over: a token typed for one server is not //
// sent to another, and an organization of one is not one of the other. The //
// default token is a GitHub token, so it is not the choice anywhere else. //
//////////////////////////////////////////////////////////////////////////////
_orgLister = listerOf(place); // first: if there is no lister for the place, the wizard stays on the one it has
_chosen = place;
Token = "";
TokenError = "";
Organizations.Clear();
Organization = "";
UseDefaultToken = place.Kind == ForgeKind.GitHub && HasDefaultToken;
OnPropertyChanged(nameof(PlaceName));
OnPropertyChanged(nameof(OffersTokenChoice));
OnPropertyChanged(nameof(TokenHeader));
OnPropertyChanged(nameof(TokenPlaceholder));
OnPropertyChanged(nameof(TokenAdvice));
OnPropertyChanged(nameof(TokenPage));
OnPropertyChanged(nameof(TokenStepNote));
}
if(place.Server is not null)
{
Server = place.Server.AbsoluteUri;
}
return true;
}
/// <summary>Returns to the previous step; a no-op on the first step, and while step 2 is checking a token.</summary>
public void GoBack()
{
if(!IsFirstStep)
if(!IsFirstStep && !IsValidatingToken)
{
Step--;
}
@@ -494,8 +720,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
// An account stays on the forge and server it was added for: its token was saved for that place.
Forge = _existing?.Forge ?? ForgeKind.GitHub,
Server = _existing?.Server,
Forge = PlaceKind,
Server = _existing is null ? _chosen.Server?.AbsoluteUri : _existing.Server,
LastSyncUtc = _existing?.LastSyncUtc,
LastSyncSummary = _existing?.LastSyncSummary,
};
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// What the desktop app says about a forge where it used to say "GitHub": the
/// page a token is made on, how the token is called there, and what it has to be
/// allowed to do. One place, so the wizard and an account's page say the same.
/// </summary>
internal static class ForgeTexts
{
private static readonly Uri s_gitHubTokenPage = new("https://github.com/settings/tokens");
/// <summary>
/// The page a person makes a token on: GitHub's, or the one of the Gitea server
/// itself. Null when the place is not known (an account whose stored address
/// cannot be used): there is no page to point at then.
/// </summary>
public static Uri? TokenPage(ForgeEndpoint? place)
=> place is null ? null
: place.Server is null ? s_gitHubTokenPage
: new Uri(place.Server, "user/settings/applications");
/// <summary>What the forge calls the token.</summary>
public static string TokenName(ForgeKind forge)
=> forge == ForgeKind.GitHub ? "Personal access token" : "Access token";
/// <summary>What the token has to be allowed to do, as the end of a sentence.</summary>
public static string TokenNeeds(ForgeKind forge)
=> forge == ForgeKind.GitHub
? "needs repository read access"
: "needs read access to repositories, organizations and the user";
}
+22
View File
@@ -173,6 +173,28 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Name shown for this workspace in navigation: the account's name, or "Quick Sync".</summary>
public string DisplayName => _account?.Name ?? "Quick Sync";
/// <summary>
/// How the place this workspace connects to is named: "GitHub" for Quick Sync
/// and a GitHub account, the server's host for an account on a Gitea server. For
/// an account whose stored place cannot be used, what <see cref="Account.DescribePlace"/> says.
/// </summary>
public string PlaceName => _account is null ? ForgeEndpoint.GitHub.DisplayName : _account.DescribePlace();
/// <summary>What the token of this workspace's forge is called: the header of its token box.</summary>
public string TokenHeader => ForgeTexts.TokenName(_account?.Forge ?? ForgeKind.GitHub);
/// <summary>The title of the connect card: where this workspace connects to.</summary>
public string ConnectTitle => $"Connect to {PlaceName}";
/// <summary>The page a token for this workspace is made on; null when the account's place is not known.</summary>
public Uri? TokenPage => ForgeTexts.TokenPage(Place);
/// <summary>The place this workspace connects to; null for an account whose stored forge and server cannot be used.</summary>
private ForgeEndpoint? Place
=> _account is null ? ForgeEndpoint.GitHub
: _account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place
: null;
/// <summary>
/// True for an account workspace whose token is the default token from Settings
/// (<see cref="TokenSource.Default"/>): the pane tags it, and Sync All names
+1 -1
View File
@@ -56,7 +56,7 @@
</Border>
<TextBlock
Text="Clone and update every repository in a GitHub organization."
Text="Clone and update every repository of a GitHub organization, or of one on a Gitea server."
TextWrapping="Wrap" />
<HyperlinkButton
+53 -12
View File
@@ -18,13 +18,15 @@
SecondaryButtonClick="OnSecondaryButtonClick">
<!-- The four step panels overlay each other; StepVisible shows exactly one. The
MinHeight is the tallest step (token + hint + validation row + error), so the
dialog keeps one footprint across steps instead of resizing and re-centering
under the pointer on every Next (#31). -->
<Grid MinWidth="400" MinHeight="280">
MinHeight is the tallest step (step 1 with the server address and its error),
and the width is fixed (a note that names a server wraps instead of widening
the dialog), so the dialog keeps one footprint across steps, and when a Gitea
server is chosen, instead of resizing and re-centering under the pointer (#31). -->
<Grid Width="440" MinHeight="360">
<!-- ============================================================ Step 1
Identity: the unique display name plus an optional note. -->
Identity: the unique display name, an optional note, and for a new
account the place it lives (GitHub, or a Gitea server). -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(1, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
@@ -48,10 +50,46 @@
ToolTipService.ToolTip="A note to future you about what this account is for"
PlaceholderText="What this account is for"
Text="{x:Bind ViewModel.Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<!-- Where the account lives (#206). Asked for a new account only: an
account stays on the place its token was saved for, and then the
note below says where that is. -->
<RadioButtons
x:Name="PlaceChoice"
AutomationProperties.AutomationId="WizardPlaceChoice"
Header="Where are the repositories?"
MaxColumns="2"
SelectedIndex="{x:Bind ViewModel.PlaceIndex, Mode=TwoWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.CanChoosePlace)}">
<RadioButton AutomationProperties.AutomationId="WizardPlaceGitHub" Content="GitHub" />
<RadioButton AutomationProperties.AutomationId="WizardPlaceGitea" Content="Gitea" />
</RadioButtons>
<TextBox
AutomationProperties.AutomationId="WizardServerBox"
Header="Server address"
ToolTipService.ToolTip="The address of the Gitea (or Forgejo) server itself. HTTPS only; the token is sent to this address and nowhere else."
PlaceholderText="https://git.example.com"
Text="{x:Bind ViewModel.Server, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.AsksForServer), Mode=OneWay}" />
<TextBlock
x:Name="ServerErrorText"
AutomationProperties.AutomationId="WizardServerError"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.ServerError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.ServerError), Mode=OneWay}" />
<TextBlock
AutomationProperties.AutomationId="WizardPlaceNote"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.PlaceNote}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.PlaceNote)}" />
</StackPanel>
<!-- ============================================================ Step 2
Access token: validated against GitHub when Next is pressed. -->
Access token: validated when Next is pressed, against the place the
account is on (step 1) and nowhere else. -->
<StackPanel Spacing="12" Visibility="{x:Bind StepVisible(2, ViewModel.Step), Mode=OneWay}">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
@@ -78,20 +116,23 @@
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="WizardTokenBox"
Header="Personal access token"
ToolTipService.ToolTip="Stored in Windows Credential Manager, never in a file; needs repository read access"
PlaceholderText="ghp_…"
Header="{x:Bind ViewModel.TokenHeader, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.TokenAdvice, Mode=OneWay}"
PlaceholderText="{x:Bind ViewModel.TokenPlaceholder, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsOwnTokenSelected, Mode=OneWay}"
PasswordChanged="TokenBox_PasswordChanged" />
<!-- The page of the place the account is on; none when that place is not known. -->
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
NavigateUri="{x:Bind ViewModel.TokenPage, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotNull(ViewModel.TokenPage), Mode=OneWay}" />
<TextBlock
AutomationProperties.AutomationId="WizardTokenStepNote"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Next validates the token and lists the organizations it can see." />
Text="{x:Bind ViewModel.TokenStepNote, Mode=OneWay}" />
<StackPanel
Orientation="Horizontal"
Spacing="8"
@@ -125,7 +166,7 @@
AutomationProperties.AutomationId="WizardOrgBox"
HorizontalAlignment="Stretch"
Header="Organization or account"
ToolTipService.ToolTip="The GitHub organization or user account this saved account syncs"
ToolTipService.ToolTip="The organization or user account this saved account syncs"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Choose from the list, or type a name"
+13 -5
View File
@@ -57,9 +57,6 @@ public sealed partial class AccountWizardDialog : ContentDialog
// by hand. The stored token of an existing account is deliberately NOT
// loaded into the box: an empty box means "keep it", and the secret never
// sits in an editable control with a reveal button (#32).
TokenBox.PlaceholderText = ViewModel.IsEditing && !ViewModel.UseDefaultToken
? "Leave empty to keep the stored token"
: "ghp_…";
// NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged).
ConcurrencyBox.Value = ViewModel.MaxConcurrency;
@@ -70,6 +67,17 @@ public sealed partial class AccountWizardDialog : ContentDialog
// after the panel has laid out, every time the wizard arrives on step 3.
ViewModel.PropertyChanged += (_, e) =>
{
///////////////////////////////////////////////////////////////////////////
// The view model empties the token when step 1 changes the place the //
// account is on (#206). The box is mirrored by hand, so it is emptied //
// here: what it shows is what would be sent. //
///////////////////////////////////////////////////////////////////////////
if(string.Equals(e.PropertyName, nameof(AccountWizardViewModel.Token), StringComparison.Ordinal)
&& ViewModel.Token.Length == 0 && TokenBox.Password.Length > 0)
{
TokenBox.Password = "";
}
if(string.Equals(e.PropertyName, nameof(AccountWizardViewModel.Step), StringComparison.Ordinal) && ViewModel.Step == 3)
{
// The panel was collapsed, so the ComboBox has no template (and no inner
@@ -106,7 +114,7 @@ public sealed partial class AccountWizardDialog : ContentDialog
/// <summary>Next is locked while the token step is talking to GitHub.</summary>
/// <summary>Next is locked while the token step is talking to the server.</summary>
public bool PrimaryEnabled(bool isValidatingToken) => !isValidatingToken;
@@ -266,7 +274,7 @@ public sealed partial class AccountWizardDialog : ContentDialog
{
TextBlock errorText = ViewModel.Step switch
{
1 => NameErrorText,
1 => ViewModel.NameError.Length > 0 ? NameErrorText : ServerErrorText,
2 => TokenErrorText,
3 => OrganizationErrorText,
_ => TargetErrorText,
+2 -1
View File
@@ -24,6 +24,7 @@
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
x:Name="TokenPageLink"
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
@@ -38,7 +39,7 @@
HorizontalAlignment="Stretch"
Header="Organization or account"
x:Name="OrgBox"
ToolTipService.ToolTip="The GitHub organization or user account whose repositories are synced. Populated from the token; typing a name also works."
ToolTipService.ToolTip="The organization or user account whose repositories are synced. Populated from the token; typing a name also works."
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
+8
View File
@@ -96,6 +96,14 @@ public sealed partial class ConnectionFormControl : UserControl
// (#182). //
///////////////////////////////////////////////////////////////////////////////
TokenBox.IsEnabled = !savedWithAnAccount;
// The page of the place this workspace connects to (#206); none when that place is not known.
if(ViewModel is not null)
{
TokenBox.Header = ViewModel.TokenHeader;
TokenPageLink.NavigateUri = ViewModel.TokenPage;
TokenPageLink.Visibility = StatusFormat.VisibleIfNotNull(ViewModel.TokenPage);
}
TokenBox.PlaceholderText = !savedWithAnAccount ? "ghp_…"
: ViewModel!.UsesDefaultToken ? "No default token is saved"
: "No token is saved for this account";
+38 -11
View File
@@ -563,10 +563,11 @@ public sealed partial class MainWindow : Window
return null;
}
// The listers are the account's own: its token goes to the forge and server it was added for.
ForgeEndpoint? place = PlaceOf(account);
return new WorkspaceViewModel(
UiTestFixture.WrapRepositoryLister(AccountListers.RepositoryListerFor(account)),
UiTestFixture.WrapRepositoryLister(AccountListers.RepositoryListerFor(account), place),
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
UiTestFixture.WrapOrganizationLister(AccountListers.OrganizationListerFor(account)),
UiTestFixture.WrapOrganizationLister(AccountListers.OrganizationListerFor(account), place),
account: account,
tokenVault: _tokenVault,
accountsStore: _accountsStore,
@@ -770,6 +771,12 @@ public sealed partial class MainWindow : Window
/// <summary>The place an account is on; null when its stored forge and server cannot be used.</summary>
private static ForgeEndpoint? PlaceOf(Account account)
=> account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null;
/// <summary>
/// Runs the account wizard: adding when <paramref name="existing"/> is null,
/// editing (seeded from the account and its vault token) otherwise. On save,
@@ -777,14 +784,33 @@ public sealed partial class MainWindow : Window
/// </summary>
private async Task ShowAccountWizardAsync(Account? existing)
{
// The vault is consulted only if step 2 needs the stored token for a
// validation call; the wizard never displays or retains it.
// Its token is checked against the place the account is on, never against another forge.
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.TokenEntryId);
IOrganizationLister organizations = existing is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(existing);
var viewModel = new AccountWizardViewModel(
_accountsStore, organizations, _settings, existing, storedToken, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
Func<string?> defaultToken = () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
AccountWizardViewModel viewModel;
if(existing is null)
{
// A new account: step 1 chooses the place, and the token of step 2 goes to
// the lister of that place and to no other (#206).
viewModel = new AccountWizardViewModel(
_accountsStore,
place => UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place),
_settings,
_log,
defaultToken);
}
else
{
// The vault is consulted only if step 2 needs the stored token for a
// validation call; the wizard never displays or retains it.
// Its token is checked against the place the account is on, never against another forge.
viewModel = new AccountWizardViewModel(
_accountsStore,
UiTestFixture.WrapOrganizationLister(AccountListers.OrganizationListerFor(existing), PlaceOf(existing)),
_settings,
existing,
() => _tokenVault.TryRetrieve(existing.TokenEntryId),
_log,
defaultToken);
}
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
@@ -833,8 +859,9 @@ public sealed partial class MainWindow : Window
/// </summary>
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
{
// A Quick Sync connection is a GitHub connection, and so is the account it is saved as.
var viewModel = new AccountWizardViewModel(
_accountsStore, new GitHubOrganizationLister(), seed, _log,
_accountsStore, UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), seed, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
+6
View File
@@ -138,6 +138,12 @@ public static class StatusFormat
/// <summary>Visible only when there is a <paramref name="value"/>; hides a link that has nowhere to go.</summary>
public static Visibility VisibleIfNotNull(object? value)
=> value is null ? Visibility.Collapsed : Visibility.Visible;
/// <summary>InfoBar severity for a run outcome (None and Canceled read as informational).</summary>
public static InfoBarSeverity SeverityFor(RunResultKind kind) => kind switch
{
+39 -12
View File
@@ -16,8 +16,9 @@ namespace gclo;
/// <summary>
/// Offline seam for the FlaUI suite (#54, #33): when the process runs with
/// <c>GCLO_UITEST_FIXTURE=1</c> <b>and</b> a workspace's token is exactly
/// <see cref="Token"/>, GitHub and git are answered by this fixture instead of the
/// network - a fixed organization with three repositories, and a git client that
/// <see cref="Token"/>, the forge and git are answered by this fixture instead of the
/// network - a fixed organization with three repositories (another one for a Gitea
/// server, with clone addresses on that server), and a git client that
/// succeeds without touching disk. Any other token (even with the variable set) goes
/// to the real services, so the existing offline-input tests keep their meaning
/// (a dummy token still gets GitHub's 401). Production never sets the variable, and an
@@ -32,6 +33,9 @@ internal static class UiTestFixture
/// <summary>The organization the fixture lists.</summary>
public const string Organization = "fixture-org";
/// <summary>The organization the fixture lists for a Gitea server (#206).</summary>
public const string GiteaOrganization = "fixture-gitea-org";
/// <summary>
/// True when the process was started for the UI suite with the fixture enabled, and
/// is not an installed build.
@@ -68,13 +72,36 @@ internal static class UiTestFixture
public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real)
=> IsEnabled ? new RepositoryLister(real) : real;
/// <summary>
/// The lister the shell uses for <paramref name="place"/> (null: GitHub): the real
/// one, answered by the fixture for the fixture token when the fixture is enabled.
/// </summary>
public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real, ForgeEndpoint? place = null)
=> IsEnabled ? new RepositoryLister(real, RepositoriesAt(place)) : real;
public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real)
=> IsEnabled ? new OrganizationLister(real) : real;
/// <summary>The organization lister for <paramref name="place"/>, wrapped the same way.</summary>
public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real, ForgeEndpoint? place = null)
=> IsEnabled ? new OrganizationLister(real, place?.Server is null ? Organization : GiteaOrganization) : real;
/// <summary>The fixture's repositories as the place would list them: on a Gitea server, with clone addresses on that server.</summary>
private static IReadOnlyList<RepoDescriptor> RepositoriesAt(ForgeEndpoint? place)
{
if(place?.Server is not Uri server)
{
return s_repositories;
}
List<RepoDescriptor> onTheServer = [];
foreach(RepoDescriptor repository in s_repositories)
{
onTheServer.Add(repository with { CloneUrl = new Uri(server, $"{GiteaOrganization}/{repository.Name}.git").AbsoluteUri });
}
return onTheServer;
}
@@ -83,22 +110,22 @@ internal static class UiTestFixture
private sealed class RepositoryLister(IRepositoryLister real) : IRepositoryLister
private sealed class RepositoryLister(IRepositoryLister real, IReadOnlyList<RepoDescriptor> repositories) : IRepositoryLister
{
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
=> string.Equals(token, Token
, StringComparison.Ordinal) ? Task.FromResult(s_repositories)
=> string.Equals(token, Token, StringComparison.Ordinal)
? Task.FromResult(repositories)
: real.ListOrganizationRepositoriesAsync(organization, token, cancellationToken);
}
private sealed class OrganizationLister(IOrganizationLister real) : IOrganizationLister
private sealed class OrganizationLister(IOrganizationLister real, string organization) : IOrganizationLister
{
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
=> string.Equals(token, Token
, StringComparison.Ordinal) ? Task.FromResult<IReadOnlyList<string>>([Organization])
=> string.Equals(token, Token, StringComparison.Ordinal)
? Task.FromResult<IReadOnlyList<string>>([organization])
: real.ListOrganizationsAsync(token, cancellationToken);
}
+5 -2
View File
@@ -115,7 +115,10 @@
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="12">
<TextBlock Style="{ThemeResource SubtitleTextBlockStyle}" Text="Connect to GitHub" />
<TextBlock
AutomationProperties.AutomationId="ConnectTitle"
Style="{ThemeResource SubtitleTextBlockStyle}"
Text="{x:Bind ViewModel.ConnectTitle, Mode=OneWay}" />
<local:ConnectionFormControl
x:Name="ConnectForm"
@@ -405,7 +408,7 @@
<Button
AutomationProperties.AutomationId="RefreshButton"
Content="Refresh"
ToolTipService.ToolTip="Reload the repository list from GitHub (Ctrl+R)"
ToolTipService.ToolTip="Reload the repository list (Ctrl+R)"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="R" Modifiers="Control" />