Engine + wizard: actionable pull errors, diff-only path validation, Octokit reuse, wizard step errors (#30)

- FetchAndPull translates NonFastForwardException into an InvalidOperationException
  that names the branch, says gclo only fast-forwards and never merges, and gives
  the ways out (push, reset, or re-clone); the detached-HEAD message gained the
  same treatment. The raw libgit2 text was the most common pull failure a user
  of this tool sees.
- WindowsPathValidator.ValidateIncoming validates only what a pull introduces
  (added/copied/renamed/type-changed paths from a tree diff with rename
  detection) plus case-only collisions against what stays in the index, instead
  of re-walking the whole incoming tree on every pull with new commits. The full
  walk remains for clones and never-checked-out repositories.
- OctokitGatewayCache keeps one Octokit client per token for the process so
  successive org lookups and loads reuse the warm HTTP connection instead of a
  fresh DNS/TCP/TLS handshake per action.
- AccountWizardViewModel: steps 3 and 4 now set OrganizationError / TargetError
  instead of silently refusing to advance, and a new seeded constructor
  (AccountWizardSeed) carries a Quick Sync connection into the wizard so it
  can be saved as an account; the seed record redacts its token.

Tests: pull-error message and inner exception; six ValidateIncoming cases
(only introduced paths reported, modified-only is empty, case collision
against existing, case change via delete+add allowed, detected rename
releases the old spelling, null args); wizard step 3/4 messages and the
seeded wizard end to end. 361 Engine tests, coverage 100%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
James MaesandClaude Fable 5.1 committed 2026-10-09 06:37:50 -05:00
1 parent 64814d1411
commit 9479a76cd8
10 files changed
+395 -31

No files matched your search

@@ -259,11 +259,13 @@ public sealed class AccountWizardViewModelTests : IDisposable
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("Choose an organization from the list, or type one.", wizard.OrganizationError);
wizard.Organization = "acme";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.True(wizard.IsLastStep);
Assert.Equal("", wizard.OrganizationError);
}
[Fact]
@@ -274,6 +276,45 @@ public sealed class AccountWizardViewModelTests : IDisposable
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.Equal("Choose a target folder.", wizard.TargetError);
wizard.TargetRoot = @"C:\repos";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("", wizard.TargetError);
}
[Fact]
public async Task SeededWizard_CarriesTheQuickSyncConnectionOver_AndSavesItsToken()
{
var seed = new AccountWizardSeed("ghp_quick", "acme", @"C:\src", CreateOrgSubfolder: true, MaxConcurrency: 12);
var wizard = new AccountWizardViewModel(_store, _orgs, seed);
Assert.Equal(1, wizard.Step);
Assert.False(wizard.IsEditing);
Assert.Equal("", wizard.Name);
Assert.Equal("ghp_quick", wizard.Token);
Assert.Equal("acme", wizard.Organization);
Assert.Equal(@"C:\src", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(12, wizard.MaxConcurrency);
Assert.DoesNotContain("ghp_quick", seed.ToString());
Assert.Contains("[redacted]", seed.ToString());
wizard.Name = "Saved from Quick Sync";
await AdvanceToStepAsync(wizard, 4);
Assert.True(await wizard.TryAdvanceAsync());
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(12, saved.MaxConcurrency);
Assert.Equal("ghp_quick", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void SeededWizard_NullSeed_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AccountWizardViewModel(_store, _orgs, (AccountWizardSeed)null!));
}
[Fact]
+9 -1
View File
@@ -265,8 +265,15 @@ public sealed class LibGit2GitClientTests : IDisposable
CommitFile(source, "from-remote.txt", "remote change", "remote commit");
CommitFile(target, "from-local.txt", "local change", "local commit");
await Assert.ThrowsAsync<NonFastForwardException>(
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
// Actionable, not libgit2's "cannot fast-forward": names the branch, says what
// gclo refuses to do, and gives the three ways out (#30).
Assert.Contains("local commits that origin does not have", ex.Message);
Assert.Contains("never merges", ex.Message);
Assert.Contains("re-clone", ex.Message);
Assert.IsType<NonFastForwardException>(ex.InnerException);
}
[Fact]
@@ -286,6 +293,7 @@ public sealed class LibGit2GitClientTests : IDisposable
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Check out a branch", ex.Message);
}
[Fact]
@@ -156,6 +156,122 @@ public sealed class WindowsPathValidationTests : IDisposable
Assert.Equal("deep content", File.ReadAllText(fullPath));
}
// ---------------------------------------------------------------- ValidateIncoming (#30)
[Fact]
public async Task ValidateIncoming_ReportsOnlyWhatThePullIntroduces()
{
// The current tree is already checked out (hence valid); a pull that adds one
// bad path must report exactly that path, and must not re-examine the rest.
string source = CreateForgedRepo(_root, ("ok.txt", "fine"), ("dir/also-ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("dir/bad:name.txt", "x"), ("ok.txt", "modified"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
var info = Assert.Single(invalid);
Assert.Equal("dir/bad:name.txt", info.RepoPath);
Assert.Equal("bad_name.txt", info.SuggestedName);
}
[Fact]
public async Task ValidateIncoming_ModifiedFilesOnly_ReturnsEmpty()
{
string source = CreateForgedRepo(_root, ("ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("ok.txt", "modified"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_NewPathDifferingOnlyByCaseFromAnExistingOne_IsFlagged()
{
string source = CreateForgedRepo(_root, ("docs/readme.md", "a"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
// Git is happy to hold both; NTFS is not.
AppendForgedCommit(source, ("Docs/README.md", "b"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
// The directory collides first; the file under it is reported once each.
Assert.Contains(invalid, i => i.RepoPath == "Docs" && i.Reason.Contains("'docs'"));
Assert.Contains(invalid, i => i.RepoPath == "Docs/README.md" && i.Reason.Contains("'docs/readme.md'"));
}
[Fact]
public async Task ValidateIncoming_CaseChangeViaDeleteAndAdd_IsAllowed()
{
// Upstream renamed 'readme.md' to 'README.md' (delete + add in one commit): the
// old spelling leaves the disk with the pull, so the new one does not collide.
string source = CreateForgedRepo(_root, ("readme.md", "a"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
definition.Remove("readme.md");
definition.Add("README.md", src.ObjectDatabase.CreateBlob(new MemoryStream("different"u8.ToArray())), Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "recase", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_DetectedRename_ChecksTheNewPathAndReleasesTheOld()
{
// Same content under a new name is a rename to libgit2: the new path is
// validated (and here is invalid), and the old path no longer counts as
// occupying its spelling.
string source = CreateForgedRepo(_root, ("old-name.txt", "identical content for rename detection"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
var blob = head.Tree["old-name.txt"].Target as Blob;
definition.Remove("old-name.txt");
definition.Add("new:name.txt", blob!, Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "rename", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
Assert.Equal("new:name.txt", Assert.Single(invalid).RepoPath);
}
[Fact]
public void ValidateIncoming_NullArguments_Throw()
{
string path = NewPath("empty");
Repository.Init(path);
using var repo = new Repository(path);
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!));
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(repo, null!, null!));
}
[Fact]
public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException()
{
+2 -6
View File
@@ -7,16 +7,12 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubOrganizationLister()
: this(CreateOctokitGateway)
: this(OctokitGatewayCache.Get)
{
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubOrganizationLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
+2 -6
View File
@@ -7,16 +7,12 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubRepositoryLister()
: this(CreateOctokitGateway)
: this(OctokitGatewayCache.Get)
{
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubRepositoryLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
+25
View File
@@ -35,6 +35,31 @@ internal interface IGitHubGateway
Task<IReadOnlyList<string>> GetOrganizationLoginsAsync();
}
/// <summary>
/// One <see cref="OctokitGateway"/> per token, shared by both listers for the life of
/// the process, so successive org lookups and repository loads ride one warm HTTP
/// connection instead of paying a DNS + TCP + TLS handshake to api.github.com on
/// every interactive action (#30). Octokit clients are thread-safe for concurrent
/// requests with fixed credentials. Bounded: tokens are rotated rarely, so when the
/// cache grows past a handful of entries it is simply emptied.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Holds live Octokit clients; the offline suite injects fake gateways.")]
internal static class OctokitGatewayCache
{
private const int MaxEntries = 8;
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, OctokitGateway> Gateways =
new(StringComparer.Ordinal);
public static IGitHubGateway Get(string token)
{
if (Gateways.Count >= MaxEntries && !Gateways.ContainsKey(token))
{
Gateways.Clear();
}
return Gateways.GetOrAdd(token, static t => new OctokitGateway(t));
}
}
/// <summary>
/// The production <see cref="IGitHubGateway"/> over Octokit. Pure delegation with
/// no branching of its own, which is why it is excluded from the coverage gate:
+17 -3
View File
@@ -226,7 +226,9 @@ public sealed class LibGit2GitClient : IGitClient
if (repo.Info.IsHeadDetached)
{
throw new InvalidOperationException("HEAD is detached; fetched, but nothing was merged.");
throw new InvalidOperationException(
"HEAD is detached (a commit is checked out, not a branch); fetched, but nothing was merged. "
+ "Check out a branch in this repository to resume updates, or delete the folder to re-clone.");
}
if (repo.Info.IsHeadUnborn)
@@ -251,9 +253,13 @@ public sealed class LibGit2GitClient : IGitClient
}
// Incoming commits can introduce Windows-invalid paths just like a clone can.
// Only what the pull introduces is checked: the current tree was validated
// when it was checked out, and re-walking a 100K-file tree for a one-file
// commit was the dominant engine cost of a daily update run (#30).
if (OperatingSystem.IsWindows())
{
var invalidIncoming = WindowsPathValidator.Validate(tracked.Tip.Tree);
// HEAD cannot be unborn here (handled above), so the current tip exists.
var invalidIncoming = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip!.Tree, tracked.Tip.Tree);
if (invalidIncoming.Count > 0)
{
throw new InvalidRepositoryPathsException(invalidIncoming);
@@ -261,7 +267,9 @@ public sealed class LibGit2GitClient : IGitClient
}
// Fast-forward only: a mirror tool must never manufacture merge commits.
// Diverged local history surfaces as NonFastForwardException -> Failed with a clear message.
// Diverged local history is the most common pull failure for this tool's
// audience (someone committed in a synced clone), so it gets a message that
// says what gclo refuses to do and what to do about it (#30).
var signature = new Signature("gclo", "gclo@localhost", DateTimeOffset.Now);
try
{
@@ -276,6 +284,12 @@ public sealed class LibGit2GitClient : IGitClient
{
throw new OperationCanceledException(ct);
}
catch (NonFastForwardException ex)
{
throw new InvalidOperationException(
$"'{repo.Head.FriendlyName}' has local commits that origin does not have; gclo only fast-forwards and never merges. "
+ "Push or reset the local commits, or delete the folder to re-clone.", ex);
}
}
/// <summary>
+94
View File
@@ -30,6 +30,100 @@ public static class WindowsPathValidator
public static IReadOnlyList<InvalidPathInfo> Validate(Tree tree)
=> ValidatePaths(FlattenPaths(tree));
/// <summary>
/// Validates only what a pull would introduce: the paths added, copied, renamed,
/// or type-changed between <paramref name="current"/> (the checked-out tree, which
/// was validated when it was materialized) and <paramref name="incoming"/>. The new
/// paths get the full segment and collision rules among themselves; case-only
/// collisions are additionally checked against everything that stays on disk
/// (the index minus deleted and renamed-away paths). Modified files keep their
/// already-valid paths and are not re-examined, so a one-file commit on a
/// 100K-file repository costs one diff, not a tree walk.
/// </summary>
public static IReadOnlyList<InvalidPathInfo> ValidateIncoming(Repository repo, Tree current, Tree incoming)
{
ArgumentNullException.ThrowIfNull(repo);
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(incoming);
var introduced = new List<string>();
var removed = new HashSet<string>(StringComparer.Ordinal);
using (TreeChanges changes = repo.Diff.Compare<TreeChanges>(
current, incoming, new CompareOptions { Similarity = SimilarityOptions.Renames }))
{
foreach (TreeEntryChanges change in changes)
{
if (change.Status == ChangeKind.Deleted)
{
removed.Add(change.Path);
continue;
}
if (change.Status == ChangeKind.Renamed)
{
removed.Add(change.OldPath);
}
if (change.Status is ChangeKind.Added or ChangeKind.Copied or ChangeKind.Renamed or ChangeKind.TypeChanged)
{
introduced.Add(change.Path);
}
}
}
if (introduced.Count == 0)
{
return [];
}
var invalid = new List<InvalidPathInfo>(ValidatePaths(introduced));
// Every path (and directory prefix) that remains on disk, case-insensitively,
// mapped to its exact spelling; a new path whose exact spelling is absent but
// whose casing matches an existing one cannot be created on Windows.
var remaining = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var remainingExact = new HashSet<string>(StringComparer.Ordinal);
foreach (IndexEntry entry in repo.Index)
{
if (!removed.Contains(entry.Path))
{
AddWithPrefixes(entry.Path, remaining, remainingExact);
}
}
var reported = new HashSet<string>(StringComparer.Ordinal);
foreach (string path in introduced)
{
string[] segments = path.Split('/');
string prefix = "";
for (int i = 0; i < segments.Length; i++)
{
prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}";
if (!remainingExact.Contains(prefix)
&& remaining.TryGetValue(prefix, out string? existing)
&& reported.Add(prefix))
{
invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{existing}'", null));
}
}
}
return invalid;
}
private static void AddWithPrefixes(
string path, Dictionary<string, string> byCase, HashSet<string> exact)
{
string[] segments = path.Split('/');
string prefix = "";
for (int i = 0; i < segments.Length; i++)
{
prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}";
if (exact.Add(prefix))
{
byCase.TryAdd(prefix, prefix);
}
}
}
/// <summary>
/// Depth-first flattening of <paramref name="tree"/> into leaf paths (blobs and
/// submodule links). An empty subtree is emitted as its own path so its name is
+24
View File
@@ -0,0 +1,24 @@
namespace gclo.ViewModels;
/// <summary>
/// The connection values a Quick Sync workspace hands to the account wizard so a
/// working ad-hoc setup can be saved as an account without re-entering anything
/// (the seeded <see cref="AccountWizardViewModel"/> constructor).
/// </summary>
/// <param name="Token">The token in effect; written to the vault on save.</param>
/// <param name="Organization">The organization (or user) the workspace is connected to.</param>
/// <param name="TargetRoot">The workspace's target folder (not the org subfolder).</param>
/// <param name="CreateOrgSubfolder">Whether clones go under TargetRoot\Organization.</param>
/// <param name="MaxConcurrency">The workspace's parallel clone count.</param>
public sealed record AccountWizardSeed(
string Token,
string Organization,
string TargetRoot,
bool CreateOrgSubfolder,
int MaxConcurrency)
{
/// <summary>Redacts the token: this record must never print it.</summary>
public override string ToString() =>
$"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, "
+ $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}";
}
+65 -15
View File
@@ -32,19 +32,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
AppSettings defaults,
Account? existing = null,
string? existingToken = null)
: this(store, orgLister, existing, existingToken ?? "")
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
ArgumentNullException.ThrowIfNull(defaults);
_store = store;
_orgLister = orgLister;
_existing = existing;
_seededToken = existingToken ?? "";
Step = 1;
NameError = "";
TokenError = "";
Token = _seededToken;
if (existing is null)
{
@@ -66,6 +56,48 @@ public sealed partial class AccountWizardViewModel : ObservableObject
}
}
/// <summary>
/// A wizard for a NEW account seeded from a working Quick Sync connection
/// (<paramref name="seed"/>): token, organization, folder, subfolder preference,
/// and parallelism are carried over so "save this connection as an account" is a
/// matter of naming it (#30). The token is treated as freshly typed, so it is
/// always written to the vault on save.
/// </summary>
public AccountWizardViewModel(AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed)
: this(store, orgLister, existing: null, seededToken: "")
{
ArgumentNullException.ThrowIfNull(seed);
Name = "";
Description = "";
Token = seed.Token;
Organization = seed.Organization;
TargetRoot = seed.TargetRoot;
CreateOrgSubfolder = seed.CreateOrgSubfolder;
MaxConcurrency = seed.MaxConcurrency;
}
private AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, Account? existing, string seededToken)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
_store = store;
_orgLister = orgLister;
_existing = existing;
_seededToken = seededToken;
Step = 1;
NameError = "";
TokenError = "";
OrganizationError = "";
TargetError = "";
Token = _seededToken;
Name = "";
Description = "";
Organization = "";
TargetRoot = "";
}
/// <summary>Current wizard step, 1 (identity) through 4 (destination).</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsFirstStep))]
@@ -112,6 +144,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject
[ObservableProperty]
public partial string TokenError { get; set; }
/// <summary>Step 3's validation message; empty once an organization is chosen.</summary>
[ObservableProperty]
public partial string OrganizationError { get; set; }
/// <summary>Step 4's validation message; empty once a target folder is chosen.</summary>
[ObservableProperty]
public partial string TargetError { get; set; }
/// <summary>Organizations the validated token can see; feeds step 3's editable dropdown.</summary>
public ObservableCollection<string> Organizations { get; } = new();
@@ -139,8 +179,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// Validates the current step. Steps 1-3 advance and return true on success; step 4
/// returns true without advancing (the host then calls <see cref="SaveAsync"/>). On
/// failure the wizard stays put, with the step's error message set where one exists
/// (<see cref="NameError"/> on step 1, <see cref="TokenError"/> on step 2).
/// failure the wizard stays put with the step's error message set
/// (<see cref="NameError"/>, <see cref="TokenError"/>, <see cref="OrganizationError"/>,
/// <see cref="TargetError"/>) — a silent no-op on Next reads as a broken button (#30).
/// </summary>
public async Task<bool> TryAdvanceAsync()
{
@@ -194,13 +235,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject
case 3:
if (string.IsNullOrWhiteSpace(Organization))
{
return false; // the dropdown allows free text, but not nothing
// The dropdown allows free text, but not nothing.
OrganizationError = "Choose an organization from the list, or type one.";
return false;
}
OrganizationError = "";
Step = 4;
return true;
default:
// Step 4: valid means "ready to save"; the host closes via SaveAsync.
return !string.IsNullOrWhiteSpace(TargetRoot);
if (string.IsNullOrWhiteSpace(TargetRoot))
{
TargetError = "Choose a target folder.";
return false;
}
TargetError = "";
return true;
}
}