fix(app): Quick Sync uses no address that is being edited or cannot be used, and forgets what was typed before a place was chosen
From the review of #210. - An address that was edited but not taken yet (the box was not left: a keyboard shortcut moves no focus) was still the place of a load started with Ctrl+R. The card now names no usable place while its address box holds another text than the address that was taken. - An address made unusable after a load left the old place's table syncable, and a token typed next went to the old server's clone addresses. Sync, Retry failed and path recovery need a usable place, as Load and Find do; the commands check for themselves, whatever started them; no account is offered from a card that names no place. - A token or organization put on the card before a place was chosen is not kept for the place that is: the view model no longer relies on the shell's disabled token box. - The form follows its view model only while it is in the tree, and its own changes of the token box are not taken for typing. - The workspace that was open is written when the window closes, not on every switch, and what the app remembers by itself is not written over a settings file that could not be read. - The offer to save the connection is made once; a failure to remember the connection does not fail the load. - README, ADR 0010, T13. Refs #209 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
fcb90d6afc
commit
a03013fb0f
10 files changed
+414
-64
No files matched your search
@@ -125,7 +125,7 @@ On launch, gclo shows a brief branded splash and then opens where you left off:
|
||||
|
||||
### Saved accounts
|
||||
|
||||
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. Quick Sync connects to GitHub. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
|
||||
Quick Sync is ad-hoc — no token is saved. Once a connection has loaded its repositories, a bar above the table offers **Save as account…**. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/gclo-wizard.png" width="820" alt="Add account wizard, step 1 of 4: account name, optional description, and where the repositories are, with a Gitea server chosen and its address entered" />
|
||||
|
||||
@@ -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
|
||||
@@ -159,6 +159,9 @@ matters most.
|
||||
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
|
||||
|
||||
@@ -101,7 +101,7 @@ flowchart LR
|
||||
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
|
||||
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token is not offered for, or sent to, a Gitea server. 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 | Accepted (same-user boundary: whoever can write these files can read the tokens) |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
private readonly List<WorkspaceViewModel> _workspaces = [];
|
||||
|
||||
/// <summary>The git client of every Quick Sync made by <see cref="QuickSync"/>: what it was asked to clone.</summary>
|
||||
private readonly FakeGitClient _git = new();
|
||||
|
||||
/// <summary>How many organizations a lister lists; one means "only your own account".</summary>
|
||||
private int _organizations = 2;
|
||||
|
||||
@@ -80,7 +83,8 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
Func<ForgeEndpoint, (IRepositoryLister Repositories, IOrganizationLister Organizations)>? listersOf = null)
|
||||
{
|
||||
var workspace = new WorkspaceViewModel(
|
||||
git: new FakeGitClient(),
|
||||
git: _git,
|
||||
progressFactory: handler => new Progress(handler),
|
||||
orgLookupDebounce: TimeSpan.FromMilliseconds(1),
|
||||
log: new NullActivityLog(),
|
||||
wsl: new FakeWslCloner(),
|
||||
@@ -150,6 +154,31 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +457,143 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[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<IReadOnlyList<string>>(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()
|
||||
{
|
||||
@@ -561,9 +727,9 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
public void TheDefaultToken_DoesNotReplaceATokenThatWasTyped_AndIsNotForAnAccount()
|
||||
{
|
||||
WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789");
|
||||
workspace.PlaceIndex = 0;
|
||||
workspace.Token = "ghp_typed_0123456789";
|
||||
|
||||
workspace.PlaceIndex = 0;
|
||||
workspace.FillInTheDefaultToken();
|
||||
|
||||
Assert.Equal("ghp_typed_0123456789", workspace.Token);
|
||||
@@ -692,7 +858,7 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
workspace.Organization = "org-at-git.example.test";
|
||||
workspace.TargetFolder = @"C:\mirror";
|
||||
|
||||
AccountWizardSeed seed = workspace.ToAccountSeed();
|
||||
AccountWizardSeed seed = Assert.IsType<AccountWizardSeed>(workspace.ToAccountSeed());
|
||||
|
||||
Assert.Equal(s_gitea, seed.Place);
|
||||
Assert.Equal("gitea-token-0123456789", seed.Token);
|
||||
@@ -817,6 +983,34 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[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]
|
||||
@@ -883,6 +1077,13 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
|
||||
|
||||
private sealed class Progress(Action<RepoProgress> handler) : IProgress<RepoProgress>
|
||||
{
|
||||
public void Report(RepoProgress value) => handler(value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Answers when the test lets it.</summary>
|
||||
private sealed class GatedRepositoryLister(Task<IReadOnlyList<RepoDescriptor>> answer) : IRepositoryLister
|
||||
{
|
||||
|
||||
@@ -112,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();
|
||||
@@ -119,6 +120,29 @@ public sealed class AppSettings
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the settings file was there and could not be read, so these are the
|
||||
/// defaults in its place. What the app remembers by itself is then not written
|
||||
/// (<see cref="SaveWhatWasRemembered"/>): the file is left for the user to mend,
|
||||
/// as it was before the app remembered anything. Saving the Settings dialog
|
||||
/// still writes.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool CouldNotBeRead { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes the settings because the app remembered something by itself (the
|
||||
/// workspace that was open, Quick Sync's last connection), not because the user
|
||||
/// saved them. Does nothing when the file could not be read at start: that file
|
||||
/// is not written over with defaults on the app's own account.
|
||||
/// </summary>
|
||||
public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null)
|
||||
=> !CouldNotBeRead && Save(log, directory);
|
||||
|
||||
|
||||
|
||||
/// <summary>How long a save tries to put the new file in place while something has the old one open.</summary>
|
||||
private static readonly TimeSpan s_savePatience = TimeSpan.FromSeconds(1);
|
||||
|
||||
|
||||
@@ -266,13 +266,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
if((value != _isOnGitea || !_placeChosen) && _listersOf is { } listersOf && CanEditInputs)
|
||||
{
|
||||
bool otherKind = value != _isOnGitea;
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// 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;
|
||||
if(otherKind)
|
||||
{
|
||||
ForgetTheOtherPlace();
|
||||
}
|
||||
ForgetTheOtherPlace();
|
||||
TakeThePlace(listersOf);
|
||||
}
|
||||
|
||||
@@ -317,7 +318,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
set
|
||||
{
|
||||
string typed = value ?? "";
|
||||
if(!string.Equals(typed, _server, StringComparison.Ordinal) && _listersOf is { } listersOf && CanEditInputs)
|
||||
if((!string.Equals(typed, _server, StringComparison.Ordinal) || !_placeIsUsable) && _listersOf is { } listersOf && CanEditInputs)
|
||||
{
|
||||
_server = typed;
|
||||
TakeThePlace(listersOf);
|
||||
@@ -326,6 +327,50 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Told by the shell that the address box is being typed into and holds another
|
||||
/// text than <see cref="Server"/>. Until the address is set again (the box is
|
||||
/// left, or Enter is pressed), the card names no place that can be used: nothing
|
||||
/// can be loaded, looked up or synced with the address that is no longer the one
|
||||
/// on screen, whatever starts it (a button, a keyboard shortcut).
|
||||
/// </summary>
|
||||
public void ServerIsBeingEdited()
|
||||
{
|
||||
if(AsksForServer && _placeIsUsable)
|
||||
{
|
||||
_placeIsUsable = false;
|
||||
SayWhatThePlaceAllows();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Says what follows from the place the card names: its texts, and what can be
|
||||
/// done with it. While it names no usable place, a lookup that is on its way is
|
||||
/// ended and the connection is not offered as an account.
|
||||
/// </summary>
|
||||
private void SayWhatThePlaceAllows()
|
||||
{
|
||||
if(!_placeIsUsable)
|
||||
{
|
||||
_orgLoadCts?.Cancel();
|
||||
SaveAsAccountOffered = false;
|
||||
}
|
||||
OnPropertyChanged(nameof(PlaceName));
|
||||
OnPropertyChanged(nameof(ConnectTitle));
|
||||
OnPropertyChanged(nameof(TokenHeader));
|
||||
OnPropertyChanged(nameof(TokenPlaceholder));
|
||||
OnPropertyChanged(nameof(TokenPage));
|
||||
LoadReposCommand.NotifyCanExecuteChanged();
|
||||
FindOrganizationsCommand.NotifyCanExecuteChanged();
|
||||
SyncCommand.NotifyCanExecuteChanged();
|
||||
RetryFailedCommand.NotifyCanExecuteChanged();
|
||||
ResolvePathsCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>What is wrong with <see cref="Server"/>; empty when it can be used, and while none was entered yet.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string ServerError { get; set; }
|
||||
@@ -404,13 +449,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
_server = place.Server.AbsoluteUri;
|
||||
OnPropertyChanged(nameof(Server));
|
||||
}
|
||||
OnPropertyChanged(nameof(PlaceName));
|
||||
OnPropertyChanged(nameof(ConnectTitle));
|
||||
OnPropertyChanged(nameof(TokenHeader));
|
||||
OnPropertyChanged(nameof(TokenPlaceholder));
|
||||
OnPropertyChanged(nameof(TokenPage));
|
||||
LoadReposCommand.NotifyCanExecuteChanged();
|
||||
FindOrganizationsCommand.NotifyCanExecuteChanged();
|
||||
SayWhatThePlaceAllows();
|
||||
FillInTheDefaultToken();
|
||||
}
|
||||
|
||||
@@ -494,17 +533,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
TargetFolder = connection.TargetFolder;
|
||||
CreateOrgSubfolder = connection.CreateOrgSubfolder;
|
||||
}
|
||||
FillInTheDefaultToken();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the account wizard needs to save this Quick Sync connection as an
|
||||
/// account, the place included: the token was used with that place.
|
||||
/// account, the place included: the token was used with that place. Null while
|
||||
/// the card names no place that can be used: there is no connection to save.
|
||||
/// </summary>
|
||||
public AccountWizardSeed ToAccountSeed()
|
||||
=> new(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace);
|
||||
public AccountWizardSeed? ToAccountSeed()
|
||||
=> _placeIsUsable ? new AccountWizardSeed(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace) : null;
|
||||
|
||||
/// <summary>
|
||||
/// True for an account workspace whose token is the default token from Settings
|
||||
@@ -721,7 +760,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// True once this workspace has completed its first successful repository load;
|
||||
/// drives the connect-card vs workspace visibility switch. Never reset.
|
||||
/// drives the connect-card vs workspace visibility switch. Set back only when
|
||||
/// Quick Sync is put on another place, which empties the table.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial bool HasLoadedRepos { get; set; }
|
||||
@@ -1320,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;
|
||||
@@ -1365,9 +1412,21 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
_log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'.");
|
||||
if(_account is null)
|
||||
{
|
||||
// The connection works: worth keeping as an account, and worth opening on next time.
|
||||
SaveAsAccountOffered = true;
|
||||
Connected?.Invoke(new QuickSyncConnection(_quickSyncPlace, organization, TargetFolder.Trim(), CreateOrgSubfolder));
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// 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)
|
||||
@@ -1398,6 +1457,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
!IsRunning
|
||||
&& !IsLoadingRepos
|
||||
&& !IsResolvingPaths
|
||||
&& _placeIsUsable
|
||||
&& !IsTableStale
|
||||
&& !string.IsNullOrWhiteSpace(TargetFolder)
|
||||
&& Repos.Any(r => r.IsSelected);
|
||||
@@ -1408,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
|
||||
@@ -1525,6 +1591,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
!IsRunning
|
||||
&& !IsLoadingRepos
|
||||
&& !IsResolvingPaths
|
||||
&& _placeIsUsable
|
||||
&& Repos.Any(r => r.Status == SyncStatus.Failed);
|
||||
|
||||
|
||||
@@ -1604,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;
|
||||
|
||||
|
||||
|
||||
@@ -1615,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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
IsTabStop="False"
|
||||
Loaded="OnLoaded">
|
||||
Loaded="OnLoaded"
|
||||
Unloaded="OnUnloaded">
|
||||
|
||||
<!-- The one connection form: for Quick Sync the place (GitHub, or Gitea and the
|
||||
server's address), then token, organization, target folder, subfolder
|
||||
@@ -57,6 +58,7 @@
|
||||
ToolTipService.ToolTip="The address of the Gitea (or Forgejo) server itself. HTTPS only; the token is sent to this address and nowhere else."
|
||||
PlaceholderText="https://git.example.com"
|
||||
Text="{x:Bind ViewModel.Server, Mode=OneWay}"
|
||||
TextChanged="ServerBox_TextChanged"
|
||||
LostFocus="ServerBox_LostFocus"
|
||||
KeyDown="ServerBox_KeyDown"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.AsksForServer), Mode=OneWay}" />
|
||||
|
||||
@@ -110,25 +110,13 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
// Quick Sync's place is chosen on this form (#209). What the form says //
|
||||
// about the token follows it, and so does the token box: the view model //
|
||||
// empties the token when another place is chosen, and fills in a saved //
|
||||
// default; the box is mirrored by hand, so it is brought along here. //
|
||||
// default; the box is mirrored by hand, so it is brought along here. //
|
||||
// Followed only while the form is in the tree (see OnUnloaded): a form //
|
||||
// that is not shown must not answer for one that is. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
StopFollowing();
|
||||
_followed = ViewModel;
|
||||
ViewModel.PropertyChanged += (_, args) =>
|
||||
{
|
||||
switch(args.PropertyName)
|
||||
{
|
||||
case nameof(WorkspaceViewModel.Token):
|
||||
FollowTheToken();
|
||||
break;
|
||||
case nameof(WorkspaceViewModel.PlaceIndex):
|
||||
ShowThePlace();
|
||||
break;
|
||||
case nameof(WorkspaceViewModel.TokenHeader) or nameof(WorkspaceViewModel.TokenPage) or nameof(WorkspaceViewModel.TokenPlaceholder)
|
||||
or nameof(WorkspaceViewModel.IsPlaceChosen):
|
||||
ApplyThePlace();
|
||||
break;
|
||||
}
|
||||
};
|
||||
_followed.PropertyChanged += OnViewModelChanged;
|
||||
}
|
||||
// The organization placeholder must not ask for a token the box already has, or cannot take.
|
||||
OrgBox.PlaceholderText = ViewModel?.CanChoosePlace == true ? "Type a name, or press Find to list them"
|
||||
@@ -149,6 +137,43 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
/// <summary>The view model whose place and token this control already follows.</summary>
|
||||
private WorkspaceViewModel? _followed;
|
||||
|
||||
/// <summary>True while <see cref="FollowTheToken"/> changes the token box itself: that is not somebody typing.</summary>
|
||||
private bool _followingTheToken;
|
||||
|
||||
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e) => StopFollowing();
|
||||
|
||||
|
||||
|
||||
private void StopFollowing()
|
||||
{
|
||||
if(_followed is not null)
|
||||
{
|
||||
_followed.PropertyChanged -= OnViewModelChanged;
|
||||
_followed = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void OnViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
||||
{
|
||||
switch(args.PropertyName)
|
||||
{
|
||||
case nameof(WorkspaceViewModel.Token):
|
||||
FollowTheToken();
|
||||
break;
|
||||
case nameof(WorkspaceViewModel.PlaceIndex):
|
||||
ShowThePlace();
|
||||
break;
|
||||
case nameof(WorkspaceViewModel.TokenHeader) or nameof(WorkspaceViewModel.TokenPage) or nameof(WorkspaceViewModel.TokenPlaceholder)
|
||||
or nameof(WorkspaceViewModel.IsPlaceChosen):
|
||||
ApplyThePlace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while <see cref="ShowThePlace"/> checks the radio buttons itself: that is not the user choosing.</summary>
|
||||
private bool _showingThePlace;
|
||||
|
||||
@@ -239,15 +264,23 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
|
||||
string token = ViewModel.Token;
|
||||
string shown = TokenBox.Password;
|
||||
if(token.Length == 0 && shown.Length > 0)
|
||||
_followingTheToken = true;
|
||||
try
|
||||
{
|
||||
TokenBox.Password = "";
|
||||
if(token.Length == 0 && shown.Length > 0)
|
||||
{
|
||||
TokenBox.Password = "";
|
||||
}
|
||||
else if(token.Length > 0
|
||||
&& !string.Equals(shown, token, StringComparison.Ordinal)
|
||||
&& !string.Equals(shown, SettingsTokenState.Mask, StringComparison.Ordinal))
|
||||
{
|
||||
TokenBox.Password = SettingsTokenState.Mask;
|
||||
}
|
||||
}
|
||||
else if(token.Length > 0
|
||||
&& !string.Equals(shown, token, StringComparison.Ordinal)
|
||||
&& !string.Equals(shown, SettingsTokenState.Mask, StringComparison.Ordinal))
|
||||
finally
|
||||
{
|
||||
TokenBox.Password = SettingsTokenState.Mask;
|
||||
_followingTheToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +293,7 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
/// </summary>
|
||||
private void CommitTheServer()
|
||||
{
|
||||
if(ViewModel is not null && ViewModel.AsksForServer && !string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
|
||||
if(ViewModel is not null && ViewModel.AsksForServer)
|
||||
{
|
||||
ViewModel.Server = ServerBox.Text;
|
||||
}
|
||||
@@ -268,6 +301,22 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The address box holds another text than the view model's address: it is being
|
||||
/// typed into. The view model is told, so that nothing uses the address that is
|
||||
/// no longer the one on screen until the box is left (a keyboard shortcut moves
|
||||
/// no focus).
|
||||
/// </summary>
|
||||
private void ServerBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
if(ViewModel is not null && !string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
|
||||
{
|
||||
ViewModel.ServerIsBeingEdited();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void ServerBox_LostFocus(object sender, RoutedEventArgs e) => CommitTheServer();
|
||||
|
||||
|
||||
@@ -288,7 +337,7 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string typed = ((PasswordBox)sender).Password;
|
||||
if(ViewModel is not null && ViewModel.AccountId is null && !_takingWhatWasTyped
|
||||
if(ViewModel is not null && ViewModel.AccountId is null && !_takingWhatWasTyped && !_followingTheToken
|
||||
&& !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -168,7 +168,8 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
// The start screen is where the user left off; with nothing recorded, their
|
||||
// first account, and Quick Sync when there is none (#209).
|
||||
Guid start = StartWorkspace.Choose(_accountsStore.GetAll(), _settings.LastWorkspace);
|
||||
Guid? workspaceAtStart = _settings.LastWorkspace;
|
||||
Guid start = StartWorkspace.Choose(_accountsStore.GetAll(), workspaceAtStart);
|
||||
WorkspaceNav.SelectedItem = WorkspaceNav.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => item.Tag is Guid id && id == start) ?? QuickSyncNavItem;
|
||||
@@ -204,6 +205,10 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
if(_settings.LastWorkspace != workspaceAtStart)
|
||||
{
|
||||
_settings.SaveWhatWasRemembered(_log);
|
||||
}
|
||||
responsiveness.Dispose();
|
||||
_logWindow?.Close(); // a log-only process would linger otherwise
|
||||
DisposeWorkspaces();
|
||||
@@ -405,12 +410,8 @@ public sealed partial class MainWindow : Window
|
||||
_currentWorkspaceId = id;
|
||||
RenderSyncAllBar(); // the link to the syncing account depends on what is on screen
|
||||
_log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'.");
|
||||
if(_settings.LastWorkspace != id)
|
||||
{
|
||||
// Where the next start opens (#209).
|
||||
_settings.LastWorkspace = id;
|
||||
_settings.Save(_log);
|
||||
}
|
||||
// Where the next start opens (#209); written when the window closes.
|
||||
_settings.LastWorkspace = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,7 +571,7 @@ public sealed partial class MainWindow : Window
|
||||
Connected = connection =>
|
||||
{
|
||||
_settings.RememberQuickSync(connection);
|
||||
_settings.Save(_log);
|
||||
_settings.SaveWhatWasRemembered(_log);
|
||||
},
|
||||
};
|
||||
if(_settings.RememberedQuickSync() is { } remembered)
|
||||
|
||||
@@ -453,7 +453,10 @@ public sealed partial class WorkspacePage : UserControl
|
||||
EditFlyout.Hide();
|
||||
if(SaveAsAccountRequested is { } save)
|
||||
{
|
||||
await save(ViewModel.ToAccountSeed());
|
||||
if(ViewModel.ToAccountSeed() is { } seed)
|
||||
{
|
||||
await save(seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user