diff --git a/docs/CLI.md b/docs/CLI.md
index d05d209..ea4e467 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -81,7 +81,7 @@ reports the path of the offending *segment* itself — a bad directory name like
`aux` is reported once as `aux`, not repeated for every file inside it:
```
-legacy-repo Failed 2 path(s) in this repository cannot be created on Windows: ...
+legacy-repo Failed 2 paths in this repository cannot be created on Windows: ...
legacy-repo aux ('aux' is a reserved Windows device name)
legacy-repo docs/spec?.md (contains a character that is invalid on Windows)
```
diff --git a/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs b/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs
new file mode 100644
index 0000000..2861de6
--- /dev/null
+++ b/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs
@@ -0,0 +1,32 @@
+namespace gclo.Engine.Tests;
+
+/// Pins the user-facing message of .
+public sealed class InvalidRepositoryPathsExceptionTests
+{
+ [Fact]
+ public void Message_SinglePath_UsesSingularWording()
+ {
+ var ex = new InvalidRepositoryPathsException(
+ [new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_")]);
+
+ Assert.StartsWith("1 path in this repository cannot be created on Windows:", ex.Message);
+ Assert.Contains("'aux' ('aux' is a reserved Windows device name)", ex.Message);
+ Assert.EndsWith("Nothing was checked out.", ex.Message);
+ }
+
+ [Fact]
+ public void Message_ManyPaths_UsesPluralWording_AndCapsExamplesAtThree()
+ {
+ var paths = Enumerable.Range(1, 5)
+ .Select(i => new InvalidPathInfo(
+ $"docs/bad:{i}.txt", "contains a character that is invalid on Windows", null))
+ .ToList();
+
+ var ex = new InvalidRepositoryPathsException(paths);
+
+ Assert.StartsWith("5 paths in this repository cannot be created on Windows:", ex.Message);
+ Assert.Contains(" and 2 more", ex.Message); // examples cap at three
+ Assert.DoesNotContain("bad:4", ex.Message);
+ Assert.EndsWith("Nothing was checked out.", ex.Message);
+ }
+}
diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
index 95f858b..1401d79 100644
--- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs
+++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
@@ -21,6 +21,17 @@ public sealed class WorkspaceViewModelTests
public void Report(RepoProgress value) => handler(value);
}
+ /// A lister that blocks until the test releases .
+ private sealed class GatedLister : IRepositoryLister
+ {
+ public TaskCompletionSource> Gate { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task> ListOrganizationRepositoriesAsync(
+ string organization, string token, CancellationToken cancellationToken = default)
+ => Gate.Task;
+ }
+
private WorkspaceViewModel CreateViewModel(
TimeSpan? debounce = null,
Account? account = null,
@@ -129,6 +140,67 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.SyncCommand.CanExecute(null));
}
+ // ---------------------------------------------------------------- connect card state
+
+ [Fact]
+ public async Task HasLoadedRepos_TurnsOnAfterTheFirstSuccessfulLoad_Only()
+ {
+ _lister.ExceptionToThrow = new InvalidOperationException("org not found");
+ var vm = CreateViewModel();
+ vm.Organization = "acme";
+ vm.Token = "token-1234567890";
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ Assert.False(vm.HasLoadedRepos);
+
+ await vm.LoadReposCommand.ExecuteAsync(null);
+ Assert.False(vm.HasLoadedRepos); // a failed load keeps the connect card
+
+ _lister.ExceptionToThrow = null;
+ _lister.Repositories = [Repo("alpha")];
+ await vm.LoadReposCommand.ExecuteAsync(null);
+ Assert.True(vm.HasLoadedRepos);
+ }
+
+ [Fact]
+ public async Task CanEditInputs_LocksDuringLoad_AndDuringSync()
+ {
+ var lister = new GatedLister();
+ var vm = new WorkspaceViewModel(lister, _git, _orgs,
+ handler => new SyncProgress(handler),
+ TimeSpan.FromMilliseconds(1),
+ new NullActivityLog());
+ vm.Organization = "acme";
+ vm.Token = "token-1234567890";
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ vm.MaxConcurrencyValue = 1;
+ Assert.True(vm.CanEditInputs);
+
+ Task load = vm.LoadReposCommand.ExecuteAsync(null);
+ Assert.False(vm.CanEditInputs); // locked while listing
+ lister.Gate.SetResult([Repo("alpha")]);
+ await load;
+ Assert.True(vm.CanEditInputs);
+
+ var gate = new TaskCompletionSource();
+ int started = 0;
+ _git.CloneHandler = async (url, path, token, onProgress, ct) =>
+ {
+ Interlocked.Increment(ref started);
+ // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve.
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ };
+ Task run = vm.SyncCommand.ExecuteAsync(null);
+ await WaitUntilAsync(() => started > 0, "the run to start");
+ Assert.False(vm.CanEditInputs); // locked while syncing
+
+ gate.SetResult();
+ await run;
+ Assert.True(vm.CanEditInputs);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
// ---------------------------------------------------------------- sync flow
[Fact]
@@ -147,7 +219,7 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // untouched by the run
Assert.Equal(SyncStatus.Done, byName["charlie"].Status);
Assert.Equal(2, vm.CompletedCount);
- Assert.Equal(3, vm.TotalCount);
+ Assert.Equal(2, vm.TotalCount); // run-scoped (#22): the run set was 2, not the table's 3
Assert.Contains("2 cloned", vm.StatusText);
Assert.True(vm.HasCompletedRun);
Assert.False(vm.IsRunning);
@@ -254,6 +326,67 @@ public sealed class WorkspaceViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
+ // ---------------------------------------------------------------- run-scoped progress
+
+ [Fact]
+ public async Task Sync_SubsetRun_ScopesProgressToTheRunSet()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"));
+ var byName = vm.Repos.ToDictionary(r => r.Name);
+ byName["bravo"].IsSelected = false;
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, vm.TotalCount); // the two selected rows, not the table's three (#22)
+ Assert.Equal(2, vm.CompletedCount);
+ Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // unselected row untouched
+
+ // The run size sticks until the next load resets the counters to the table.
+ await vm.LoadReposCommand.ExecuteAsync(null);
+ Assert.Equal(3, vm.TotalCount);
+ Assert.Equal(0, vm.CompletedCount);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ // ---------------------------------------------------------------- active strip
+
+ [Fact]
+ public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds()
+ {
+ var gateAlpha = new TaskCompletionSource();
+ var gateBravo = new TaskCompletionSource();
+ _git.CloneHandler = async (url, path, token, onProgress, ct) =>
+ {
+ Task gate = Path.GetFileName(path) == "alpha" ? gateAlpha.Task : gateBravo.Task;
+ // CancellationToken.None: the gates must not be cancelable — the timeout is the safety valve.
+ await gate.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ };
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); // concurrency 1
+ var byName = vm.Repos.ToDictionary(r => r.Name);
+ Assert.Empty(vm.ActiveRepos);
+
+ Task run = vm.SyncCommand.ExecuteAsync(null);
+ await WaitUntilAsync(
+ () => byName["alpha"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1,
+ "alpha to enter the active strip");
+ Assert.Equal("alpha", Assert.Single(vm.ActiveRepos).Name); // bounded by parallelism (1)
+
+ gateAlpha.SetResult();
+ await WaitUntilAsync(
+ () => byName["bravo"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1,
+ "alpha to leave and bravo to enter the active strip");
+ Assert.Equal("bravo", Assert.Single(vm.ActiveRepos).Name); // alpha was removed on Done
+
+ gateBravo.SetResult();
+ await run;
+
+ Assert.Empty(vm.ActiveRepos); // cleared when the run ends
+ Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status));
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
// ---------------------------------------------------------------- retry failed
[Fact]
@@ -282,12 +415,148 @@ public sealed class WorkspaceViewModelTests
Assert.True(byName["bravo"].IsSelected); // retry selected exactly the failures
Assert.False(byName["alpha"].IsSelected);
Assert.False(byName["charlie"].IsSelected);
- Assert.Equal(3, vm.CompletedCount);
+ Assert.Equal(1, vm.TotalCount); // run-scoped (#22): the retry run was just bravo
+ Assert.Equal(1, vm.CompletedCount);
Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore
Directory.Delete(vm.TargetFolder, recursive: true);
}
+ // ---------------------------------------------------------------- run results
+
+ [Fact]
+ public async Task Sync_AllSucceed_ReportsASuccessResult()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+ Assert.False(vm.ResultOpen); // nothing to show before the first run
+ Assert.Equal(RunResultKind.None, vm.ResultKind);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.True(vm.ResultOpen);
+ Assert.Equal(RunResultKind.Success, vm.ResultKind);
+ Assert.Contains("2 cloned", vm.ResultMessage);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task Sync_WithAFailure_ReportsAPartialFailureResult()
+ {
+ _git.CloneHandler = (url, path, token, onProgress, ct) =>
+ Path.GetFileName(path) == "bravo"
+ ? Task.FromException(new InvalidOperationException("boom"))
+ : Task.CompletedTask;
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.True(vm.ResultOpen);
+ Assert.Equal(RunResultKind.PartialFailure, vm.ResultKind);
+ Assert.Contains("1 failed", vm.ResultMessage);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task Sync_Canceled_ReportsACanceledResult()
+ {
+ var gate = new TaskCompletionSource();
+ int started = 0;
+ _git.CloneHandler = async (url, path, token, onProgress, ct) =>
+ {
+ Interlocked.Increment(ref started);
+ // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve.
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ ct.ThrowIfCancellationRequested();
+ };
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+
+ Task run = vm.SyncCommand.ExecuteAsync(null);
+ await WaitUntilAsync(() => started > 0, "first clone to start");
+ vm.SyncCancelCommand.Execute(null);
+ gate.SetResult();
+ await run;
+
+ Assert.True(vm.ResultOpen);
+ Assert.Equal(RunResultKind.Canceled, vm.ResultKind);
+ Assert.StartsWith("Canceled", vm.ResultMessage);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task Sync_RunLevelError_ReportsAnErrorResult()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ // A file squats on the target root, so the engine's CreateDirectory throws
+ // before any repository is processed.
+ Directory.CreateDirectory(Path.GetDirectoryName(vm.TargetFolder)!);
+ File.WriteAllText(vm.TargetFolder, "not a directory");
+ try
+ {
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.True(vm.ResultOpen);
+ Assert.Equal(RunResultKind.Error, vm.ResultKind);
+ Assert.Equal(vm.StatusText, vm.ResultMessage); // the error line, verbatim
+ Assert.NotEmpty(vm.ResultMessage);
+ Assert.False(vm.IsRunning);
+ }
+ finally
+ {
+ File.Delete(vm.TargetFolder);
+ }
+ }
+
+ [Fact]
+ public async Task Sync_NextRun_ResetsTheResultSurface_WhileRunning()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ await vm.SyncCommand.ExecuteAsync(null);
+ Assert.True(vm.ResultOpen);
+
+ var gate = new TaskCompletionSource();
+ int started = 0;
+ _git.CloneHandler = async (url, path, token, onProgress, ct) =>
+ {
+ Interlocked.Increment(ref started);
+ // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve.
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ };
+
+ Task run = vm.SyncCommand.ExecuteAsync(null);
+ await WaitUntilAsync(() => started > 0, "second run to start");
+
+ Assert.False(vm.ResultOpen); // the stale result is gone while the new run is live
+ Assert.Equal(RunResultKind.None, vm.ResultKind);
+ Assert.Equal("", vm.ResultMessage);
+
+ gate.SetResult();
+ await run;
+
+ Assert.True(vm.ResultOpen);
+ Assert.Equal(RunResultKind.Success, vm.ResultKind);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task LoadRepos_ClearsTheResultSurface()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ await vm.SyncCommand.ExecuteAsync(null);
+ Assert.True(vm.ResultOpen);
+
+ await vm.LoadReposCommand.ExecuteAsync(null);
+
+ Assert.False(vm.ResultOpen);
+ Assert.Equal(RunResultKind.None, vm.ResultKind);
+ Assert.Equal("", vm.ResultMessage);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
// ---------------------------------------------------------------- path recovery
private static readonly IReadOnlyList SampleInvalidPaths =
@@ -361,6 +630,34 @@ public sealed class WorkspaceViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
+ [Fact]
+ public async Task ResolvePaths_ShowsPulling_WhileTheRecoveryIsInFlight()
+ {
+ MakeAlphaFailWithInvalidPaths();
+ var gate = new TaskCompletionSource();
+ _git.ApplyRecoveryHandler = async (_, _, _) =>
+ // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve.
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ await vm.SyncCommand.ExecuteAsync(null);
+ RepoItemViewModel row = vm.Repos[0];
+ vm.RecoveryInteraction = _ => Task.FromResult(
+ new PathRecovery(new Dictionary(), new HashSet()));
+
+ Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row);
+ await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight recovery to show");
+
+ Assert.True(row.ShowProgress); // the row's indeterminate bar is visible
+ Assert.True(row.IsIndeterminate);
+
+ gate.SetResult();
+ await resolve;
+
+ Assert.Equal(SyncStatus.Done, row.Status);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
[Fact]
public async Task ResolvePaths_InteractionReturnsNull_LeavesRowFailed()
{
@@ -506,6 +803,31 @@ public sealed class WorkspaceViewModelTests
Assert.True(vm.AllSelected);
}
+ [Fact]
+ public async Task SelectedCount_AndSyncButtonLabel_FollowSelectionChanges()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"));
+ var raised = new List();
+ vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
+
+ Assert.Equal(3, vm.SelectedCount);
+ Assert.Equal("Sync 3 repos", vm.SyncButtonLabel);
+
+ vm.Repos[0].IsSelected = false;
+ vm.Repos[1].IsSelected = false;
+ Assert.Equal(1, vm.SelectedCount);
+ Assert.Equal("Sync 1 repo", vm.SyncButtonLabel); // singular
+ Assert.Contains(nameof(WorkspaceViewModel.SyncButtonLabel), raised);
+
+ vm.Repos[2].IsSelected = false;
+ Assert.Equal(0, vm.SelectedCount);
+ Assert.Equal("Sync 0 repos", vm.SyncButtonLabel);
+
+ vm.AllSelected = true; // header toggle recomputes too
+ Assert.Equal(3, vm.SelectedCount);
+ Assert.Equal("Sync 3 repos", vm.SyncButtonLabel);
+ }
+
// ---------------------------------------------------------------- sorting
[Fact]
@@ -542,6 +864,80 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(["alpha", "charlie", "bravo"], vm.Repos.Select(r => r.Name));
}
+ // ---------------------------------------------------------------- filtering
+
+ [Fact]
+ public async Task FilteredRepos_DefaultAll_MirrorsTheTableAfterLoad()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+
+ Assert.Equal(RepoFilter.All, vm.Filter);
+ Assert.Equal(vm.Repos, vm.FilteredRepos);
+ }
+
+ [Fact]
+ public async Task Filter_RebuildsFilteredRepos_PerMode()
+ {
+ var vm = await CreateLoadedViewModelAsync(
+ Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta"));
+ var byName = vm.Repos.ToDictionary(r => r.Name);
+ byName["alpha"].Status = SyncStatus.Failed;
+ byName["bravo"].Status = SyncStatus.Cloning;
+ byName["charlie"].Status = SyncStatus.Pulling;
+ // delta stays Queued
+
+ vm.Filter = RepoFilter.Active;
+ Assert.Equal(["bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name));
+
+ vm.Filter = RepoFilter.Failed;
+ Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name));
+
+ vm.Filter = RepoFilter.Pending;
+ Assert.Equal(["delta"], vm.FilteredRepos.Select(r => r.Name));
+
+ vm.Filter = RepoFilter.All;
+ Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name));
+ }
+
+ [Fact]
+ public async Task Filter_WithNoMatchingRows_LeavesFilteredReposEmpty()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+
+ vm.Filter = RepoFilter.Failed; // nothing has failed
+
+ Assert.Empty(vm.FilteredRepos);
+ Assert.NotEmpty(vm.Repos); // the empty-filter hint case: rows exist, none match
+ }
+
+ [Fact]
+ public async Task FilteredRepos_FollowsTerminalTransitions_AcrossARun()
+ {
+ _git.CloneHandler = (url, path, token, onProgress, ct) =>
+ Path.GetFileName(path) == "bravo"
+ ? Task.FromException(new InvalidOperationException("boom"))
+ : Task.CompletedTask;
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
+ vm.Filter = RepoFilter.Failed;
+ Assert.Empty(vm.FilteredRepos);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name));
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task FilteredRepos_PreservesTheTableSortOrder()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie"));
+
+ vm.SortCommand.Execute("Name");
+
+ Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name));
+ }
+
// ---------------------------------------------------------------- target preview
[Fact]
@@ -911,4 +1307,98 @@ public sealed class WorkspaceViewModelTests
Assert.Equal("", item.BranchText);
}
+
+ [Fact]
+ public async Task ActiveFilter_FollowsNonTerminalTransitions_DuringARun()
+ {
+ var gate = new TaskCompletionSource();
+ _git.CloneHandler = async (_, _, _, _, _) =>
+ // CancellationToken.None: the gate must not be cancelable; the timeout is the safety valve.
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ vm.Filter = RepoFilter.Active;
+ Assert.Empty(vm.FilteredRepos); // nothing in flight yet
+
+ Task run = vm.SyncCommand.ExecuteAsync(null);
+ await WaitUntilAsync(
+ () => vm.FilteredRepos.Count == 1 && vm.FilteredRepos[0].Name == "alpha",
+ "the cloning row to enter the Active filter");
+
+ gate.SetResult();
+ await run;
+ Assert.Empty(vm.FilteredRepos); // done rows leave the Active view
+ }
+
+ /// A lister whose calls each await their own gate, in order.
+ private sealed class SequencedGatedLister : IRepositoryLister
+ {
+ public Queue>> Gates { get; } = new();
+
+ public Task> ListOrganizationRepositoriesAsync(
+ string organization, string token, CancellationToken cancellationToken = default)
+ => Gates.Dequeue().Task;
+ }
+
+ [Fact]
+ public async Task Sync_IsBlocked_WhileAReloadIsInFlight()
+ {
+ var lister = new SequencedGatedLister();
+ var first = new TaskCompletionSource>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var second = new TaskCompletionSource>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ lister.Gates.Enqueue(first);
+ lister.Gates.Enqueue(second);
+
+ var vm = new WorkspaceViewModel(lister, _git, _orgs,
+ handler => new SyncProgress(handler),
+ TimeSpan.FromMilliseconds(1),
+ new NullActivityLog());
+ vm.Organization = "acme";
+ vm.Token = "token-1234567890";
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+
+ Task firstLoad = vm.LoadReposCommand.ExecuteAsync(null);
+ first.SetResult([Repo("alpha")]);
+ await firstLoad; // table populated, alpha selected
+ Assert.True(vm.SyncCommand.CanExecute(null));
+
+ Task reload = vm.LoadReposCommand.ExecuteAsync(null);
+ await WaitUntilAsync(() => vm.IsLoadingRepos, "the reload to start");
+ Assert.False(vm.SyncCommand.CanExecute(null));
+ Assert.False(vm.RetryFailedCommand.CanExecute(null));
+
+ second.SetResult([Repo("alpha")]);
+ await reload;
+ Assert.True(vm.SyncCommand.CanExecute(null));
+ }
+
+ [Fact]
+ public async Task PathRecovery_LocksOutEveryOtherGitEntryPoint_WhileInFlight()
+ {
+ MakeAlphaFailWithInvalidPaths();
+ var gate = new TaskCompletionSource();
+ _git.ApplyRecoveryHandler = async (_, _, _) =>
+ await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ await vm.SyncCommand.ExecuteAsync(null);
+ vm.RecoveryInteraction = _ => Task.FromResult(
+ new PathRecovery(new Dictionary(), new HashSet()));
+
+ Task resolve = vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
+ await WaitUntilAsync(() => vm.IsResolvingPaths, "the recovery to start");
+ Assert.False(vm.SyncCommand.CanExecute(null));
+ Assert.False(vm.LoadReposCommand.CanExecute(null));
+ Assert.False(vm.RetryFailedCommand.CanExecute(null));
+ Assert.False(vm.CanEditInputs);
+
+ gate.SetResult();
+ await resolve;
+ Assert.False(vm.IsResolvingPaths);
+ Assert.True(vm.SyncCommand.CanExecute(null));
+ Assert.True(vm.CanEditInputs);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
}
diff --git a/gclo.Engine/InvalidRepositoryPathsException.cs b/gclo.Engine/InvalidRepositoryPathsException.cs
index 4c063e8..010cf43 100644
--- a/gclo.Engine/InvalidRepositoryPathsException.cs
+++ b/gclo.Engine/InvalidRepositoryPathsException.cs
@@ -21,7 +21,8 @@ public sealed class InvalidRepositoryPathsException : Exception
{
var examples = string.Join("; ", paths.Take(3).Select(p => $"'{p.RepoPath}' ({p.Reason})"));
var suffix = paths.Count > 3 ? $" and {paths.Count - 3} more" : "";
- return $"{paths.Count} path(s) in this repository cannot be created on Windows: {examples}{suffix}. " +
+ string subject = paths.Count == 1 ? "1 path" : $"{paths.Count} paths";
+ return $"{subject} in this repository cannot be created on Windows: {examples}{suffix}. " +
"Nothing was checked out.";
}
}
diff --git a/gclo.ViewModels/RepoFilter.cs b/gclo.ViewModels/RepoFilter.cs
new file mode 100644
index 0000000..79ec8b5
--- /dev/null
+++ b/gclo.ViewModels/RepoFilter.cs
@@ -0,0 +1,17 @@
+namespace gclo.ViewModels;
+
+/// Which repository rows the workspace table shows.
+public enum RepoFilter
+{
+ /// Every loaded repository.
+ All,
+
+ /// Rows with a git operation in flight (cloning or pulling).
+ Active,
+
+ /// Rows whose last run failed.
+ Failed,
+
+ /// Rows still queued and waiting for a worker.
+ Pending,
+}
diff --git a/gclo.ViewModels/RunResultKind.cs b/gclo.ViewModels/RunResultKind.cs
new file mode 100644
index 0000000..68750f2
--- /dev/null
+++ b/gclo.ViewModels/RunResultKind.cs
@@ -0,0 +1,20 @@
+namespace gclo.ViewModels;
+
+/// Outcome of the most recent sync run; drives the results InfoBar's severity.
+public enum RunResultKind
+{
+ /// No result to show: nothing has run yet, or a new run/load reset it.
+ None,
+
+ /// The run finished and every repository succeeded.
+ Success,
+
+ /// The run finished but at least one repository failed.
+ PartialFailure,
+
+ /// The run was canceled before every repository finished.
+ Canceled,
+
+ /// The run itself faulted (for example, the target root could not be created).
+ Error,
+}
diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs
index 43ad048..c180352 100644
--- a/gclo.ViewModels/WorkspaceViewModel.cs
+++ b/gclo.ViewModels/WorkspaceViewModel.cs
@@ -27,12 +27,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _orgLoadCts;
+ /// Canceled on dispose so an in-flight path recovery dies with the workspace.
+ private readonly CancellationTokenSource _lifetimeCts = new();
+
/// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other.
private bool _syncingSelection;
/// First repository name from the last load; makes concrete.
private string? _sampleRepoName;
+ ///
+ /// Rows captured for the in-flight (or most recent) sync run. Progress is scoped to
+ /// this set (#22): / describe the
+ /// run, not the whole table. Empty until the first run and reset by each load.
+ ///
+ private IReadOnlyList _runSet = [];
+
///
/// Production dependencies by default; pass fakes for testing. The default progress
/// factory is , which marshals via the SynchronizationContext
@@ -66,7 +76,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
TargetFolder = "";
MaxConcurrency = AppSettings.DefaultConcurrency;
StatusText = "";
+ ResultMessage = "";
AllSelected = true;
+ CanEditInputs = true;
if (account is not null)
{
@@ -87,6 +99,20 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
public ObservableCollection Repos { get; } = new();
+ ///
+ /// The rows the table shows: the subset of matching
+ /// , in the table's current sort order. Rebuilt when the filter
+ /// changes, a load completes, a run starts or ends, and on terminal row transitions.
+ ///
+ public ObservableCollection FilteredRepos { get; } = new();
+
+ ///
+ /// Rows with a git operation in flight right now; feeds the pinned active strip.
+ /// Maintained incrementally from progress reports, so its size is bounded by
+ /// . Cleared when a run starts and again when it ends.
+ ///
+ public ObservableCollection ActiveRepos { get; } = new();
+
/// Organizations discovered from the current token; feeds the org dropdown.
public ObservableCollection Organizations { get; } = new();
@@ -127,8 +153,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// True while is listing repositories.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SyncCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
public partial bool IsLoadingRepos { get; set; }
+ ///
+ /// True while a path-recovery checkout runs. Recovery writes a repository's working
+ /// tree outside a sync run, so every other git entry point is locked out for its
+ /// duration — two concurrent operations on one directory corrupt it.
+ ///
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SyncCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))]
+ public partial bool IsResolvingPaths { get; set; }
+
/// When set, repositories are placed under TargetFolder\Organization.
[ObservableProperty]
public partial bool CreateOrgSubfolder { get; set; }
@@ -152,6 +192,64 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool SortDescending { get; set; }
+ /// Which rows the repository table shows; defaults to .
+ [ObservableProperty]
+ public partial RepoFilter Filter { get; set; }
+
+ /// Number of rows currently selected for the next sync.
+ [ObservableProperty]
+ public partial int SelectedCount { get; set; }
+
+ /// Caption for the primary sync button, pluralized for the current selection.
+ public string SyncButtonLabel => SelectedCount == 1 ? "Sync 1 repo" : $"Sync {SelectedCount} repos";
+
+ /// True when the connect inputs may be edited: no load and no run in flight.
+ [ObservableProperty]
+ public partial bool CanEditInputs { get; set; }
+
+ ///
+ /// True once this workspace has completed its first successful repository load;
+ /// drives the connect-card vs workspace visibility switch. Never reset.
+ ///
+ [ObservableProperty]
+ public partial bool HasLoadedRepos { get; set; }
+
+ /// Outcome of the most recent run; maps to the results InfoBar's severity.
+ [ObservableProperty]
+ public partial RunResultKind ResultKind { get; set; }
+
+ /// Summary or error line of the most recent run, shown in the results InfoBar.
+ [ObservableProperty]
+ public partial string ResultMessage { get; set; }
+
+ ///
+ /// True while the results InfoBar is showing: set when a run ends, cleared when the
+ /// next run or load starts. The page two-way binds it so the user can dismiss the bar.
+ ///
+ [ObservableProperty]
+ public partial bool ResultOpen { get; set; }
+
+ partial void OnFilterChanged(RepoFilter value) => RebuildFilteredRepos();
+
+ partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(SyncButtonLabel));
+
+ partial void OnIsRunningChanged(bool value) => UpdateCanEditInputs();
+
+ partial void OnIsLoadingReposChanged(bool value) => UpdateCanEditInputs();
+
+ partial void OnIsResolvingPathsChanged(bool value) => UpdateCanEditInputs();
+
+ private void UpdateCanEditInputs()
+ => CanEditInputs = !IsRunning && !IsLoadingRepos && !IsResolvingPaths;
+
+ /// Clears the previous run's result surface; called when a new run or load starts.
+ private void ResetRunResult()
+ {
+ ResultOpen = false;
+ ResultKind = RunResultKind.None;
+ ResultMessage = "";
+ }
+
/// Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization.
public string EffectiveTargetRoot
{
@@ -193,12 +291,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// continuations below stay on the UI thread and may touch Organizations directly.
partial void OnTokenChanged(string value) => _ = RefreshOrganizationsAsync();
- /// Stops and releases the in-flight org lookup, if any.
+ /// Stops the in-flight org lookup and path recovery, if any.
public void Dispose()
{
_orgLoadCts?.Cancel();
_orgLoadCts?.Dispose();
_orgLoadCts = null;
+ _lifetimeCts.Cancel();
+ _lifetimeCts.Dispose();
}
/// Debounced: each token edit cancels the previous lookup.
@@ -306,6 +406,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
_syncingSelection = false;
}
+ RecomputeSelectedCount();
SyncCommand.NotifyCanExecuteChanged();
}
@@ -323,11 +424,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
if (!_syncingSelection)
{
+ // A header push recomputes once after its loop instead of per row.
+ RecomputeSelectedCount();
UpdateAllSelectedFromItems();
}
SyncCommand.NotifyCanExecuteChanged();
}
+ private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected);
+
private void UpdateAllSelectedFromItems()
{
bool all = Repos.All(r => r.IsSelected);
@@ -350,6 +455,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private bool CanLoadRepos() =>
!IsRunning
&& !IsLoadingRepos
+ && !IsResolvingPaths
&& !string.IsNullOrWhiteSpace(Organization)
&& !string.IsNullOrWhiteSpace(Token);
@@ -358,6 +464,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private async Task LoadReposAsync()
{
IsLoadingRepos = true;
+ ResetRunResult();
try
{
string organization = Organization.Trim();
@@ -388,10 +495,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
TotalCount = Repos.Count;
CompletedCount = 0;
+ _runSet = []; // progress is table-scoped again until the next run
SortColumn = null;
SortDescending = false;
_sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null;
UpdateAllSelectedFromItems();
+ HasLoadedRepos = true;
StatusText = $"{Repos.Count} repositories loaded.";
_log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'.");
}
@@ -404,6 +513,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
IsLoadingRepos = false;
NotifyTargetPathChanged();
+ RecomputeSelectedCount();
+ RebuildFilteredRepos();
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
RecomputeHasFailedRepos();
@@ -414,6 +525,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private bool CanSync() =>
!IsRunning
+ && !IsLoadingRepos
+ && !IsResolvingPaths
&& !string.IsNullOrWhiteSpace(TargetFolder)
&& Repos.Any(r => r.IsSelected);
@@ -422,6 +535,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private async Task SyncAsync(CancellationToken cancellationToken)
{
IsRunning = true;
+ ResetRunResult();
+ ActiveRepos.Clear();
try
{
var selected = Repos.Where(r => r.IsSelected).ToList();
@@ -432,6 +547,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
item.Percent = null;
item.InvalidPaths = null;
}
+ // Progress is run-scoped (#22): the bar and 'N of M' describe this run's
+ // selection, not the whole table; TotalCount keeps the run size afterwards.
+ _runSet = selected;
+ TotalCount = selected.Count;
RecomputeCompletedCount();
string targetRoot = EffectiveTargetRoot;
@@ -455,6 +574,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
StatusText = summaryText;
_log.Info(summaryText);
+ ResultKind = summary.WasCanceled ? RunResultKind.Canceled
+ : summary.Failed > 0 ? RunResultKind.PartialFailure
+ : RunResultKind.Success;
+ ResultMessage = summaryText;
+
if (_account is not null && _accountsStore is not null)
{
try
@@ -473,24 +597,34 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
catch (OperationCanceledException)
{
StatusText = "Canceled";
+ ResultKind = RunResultKind.Canceled;
+ ResultMessage = "Canceled";
_log.Info("Sync canceled.");
}
catch (Exception ex)
{
StatusText = ex.Message;
+ ResultKind = RunResultKind.Error;
+ ResultMessage = ex.Message;
_log.Error($"Sync failed: {ex.Message}", ex);
}
finally
{
HasCompletedRun = true;
IsRunning = false;
+ ActiveRepos.Clear();
+ RebuildFilteredRepos();
+ ResultOpen = true;
}
}
// ---------------------------------------------------------------- retry
private bool CanRetryFailed() =>
- !IsRunning && Repos.Any(r => r.Status == SyncStatus.Failed);
+ !IsRunning
+ && !IsLoadingRepos
+ && !IsResolvingPaths
+ && Repos.Any(r => r.Status == SyncStatus.Failed);
/// Selects exactly the failed repositories and runs the same sync path again.
[RelayCommand(CanExecute = nameof(CanRetryFailed))]
@@ -514,7 +648,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
public Func>? RecoveryInteraction { get; set; }
private bool CanResolvePaths(RepoItemViewModel item)
- => item is not null && item.HasPathIssue && !IsRunning;
+ => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths;
///
/// Asks the view (via ) how to rename or skip the
@@ -535,11 +669,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
string path = Path.Combine(EffectiveTargetRoot, item.Name);
+ IsResolvingPaths = true;
try
{
_log.Info($"{item.Name}: applying path recovery "
+ $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped).");
- await _git.ApplyRecoveryAsync(path, recovery, CancellationToken.None);
+ // Pulling shows the row's indeterminate bar while the checkout runs.
+ item.Status = SyncStatus.Pulling;
+ await _git.ApplyRecoveryAsync(path, recovery, _lifetimeCts.Token);
item.Status = SyncStatus.Done;
item.Error = null;
@@ -556,6 +693,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
_log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex);
}
+ finally
+ {
+ IsResolvingPaths = false;
+ }
RecomputeCompletedCount();
RetryFailedCommand.NotifyCanExecuteChanged();
}
@@ -601,6 +742,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
Repos.Move(current, target);
}
}
+
+ RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order
}
/// OrderBy/OrderByDescending are both stable: equal keys keep their current order.
@@ -626,6 +769,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
return; // rows are created at load time; ignore anything unknown
}
+ SyncStatus statusBefore = item.Status;
if (report.Status != SyncStatus.Queued)
{
item.Status = report.Status;
@@ -634,6 +778,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
item.InvalidPaths = report.InvalidPaths;
}
+ // Active-strip membership is maintained incrementally: a full rescan per report
+ // would cost O(rows) on every progress tick. Contains/Remove stay cheap because
+ // the collection is bounded by MaxConcurrency.
+ bool isActive = item.Status is SyncStatus.Cloning or SyncStatus.Pulling;
+ if (isActive)
+ {
+ if (!ActiveRepos.Contains(item))
+ {
+ ActiveRepos.Add(item);
+ }
+ }
+ else
+ {
+ ActiveRepos.Remove(item);
+ }
+
if (report.Status == SyncStatus.Failed)
{
_log.Error($"{report.RepoName} failed: {report.Error}");
@@ -646,12 +806,25 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
RecomputeCompletedCount();
}
+ else if (statusBefore != item.Status && Filter is RepoFilter.Active or RepoFilter.Pending)
+ {
+ // Status-dependent filters must follow NON-terminal transitions too (a row
+ // entering Cloning leaves Pending and joins Active immediately); terminal
+ // ones already rebuild via RecomputeCompletedCount, and the rebuild's
+ // sequence check keeps redundant calls cheap and scroll-stable.
+ RebuildFilteredRepos();
+ }
}
+ ///
+ /// Recounts terminal rows within the captured run set (#22) — before the first run
+ /// the set is empty and the count stays 0. Also the hook for everything that follows
+ /// a terminal transition: the failed-rows flag and the filtered table.
+ ///
private void RecomputeCompletedCount()
{
int completed = 0;
- foreach (RepoItemViewModel repo in Repos)
+ foreach (RepoItemViewModel repo in _runSet)
{
if (repo.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled)
{
@@ -660,8 +833,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
CompletedCount = completed;
RecomputeHasFailedRepos();
+ RebuildFilteredRepos();
}
private void RecomputeHasFailedRepos()
=> HasFailedRepos = Repos.Any(r => r.Status == SyncStatus.Failed);
+
+ // ---------------------------------------------------------------- filtering
+
+ ///
+ /// Rebuilds from , preserving the
+ /// table's current sort order. A no-op when the visible set is already correct, so
+ /// list controls keep their scroll position across progress ticks.
+ ///
+ private void RebuildFilteredRepos()
+ {
+ List desired = Repos.Where(MatchesFilter).ToList();
+ if (desired.SequenceEqual(FilteredRepos))
+ {
+ return;
+ }
+
+ FilteredRepos.Clear();
+ foreach (RepoItemViewModel repo in desired)
+ {
+ FilteredRepos.Add(repo);
+ }
+ }
+
+ private bool MatchesFilter(RepoItemViewModel repo) => Filter switch
+ {
+ RepoFilter.Active => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling,
+ RepoFilter.Failed => repo.Status == SyncStatus.Failed,
+ RepoFilter.Pending => repo.Status == SyncStatus.Queued,
+ _ => true,
+ };
}
diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml
index 4970ea7..8bba641 100644
--- a/gclo/MainWindow.xaml
+++ b/gclo/MainWindow.xaml
@@ -20,19 +20,19 @@
-
-
+
+
-
+
-
-
+
+
-
-
-
+
+
+
-
+
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index 00b23f3..bb25e43 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using gclo.Engine;
using gclo.Services;
using gclo.ViewModels;
+using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
@@ -23,6 +24,11 @@ namespace gclo
{
private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo";
+ // Smallest logical (DPI-independent) size at which the workspace toolbar,
+ // connect card, and repo table remain usable.
+ private const int MinWindowWidth = 700;
+ private const int MinWindowHeight = 520;
+
private readonly AppSettings _settings;
private readonly UpdateService _updateService = new();
@@ -88,6 +94,16 @@ namespace gclo
double scale = GetDpiForWindow(hwnd) / 96.0;
AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale)));
+ // Keep the window from shrinking below a usable workspace layout.
+ // PreferredMinimum* takes physical pixels like Resize (WinAppSDK 1.7+,
+ // present in the installed SDK). If a future SDK removes these
+ // properties, clamp instead from an AppWindow.Changed handler.
+ if (AppWindow.Presenter is OverlappedPresenter presenter)
+ {
+ presenter.PreferredMinimumWidth = (int)(MinWindowWidth * scale);
+ presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale);
+ }
+
Closed += (_, _) => DisposeWorkspaces();
}
diff --git a/gclo/StatusFormat.cs b/gclo/StatusFormat.cs
index 0c6d904..7542d2e 100644
--- a/gclo/StatusFormat.cs
+++ b/gclo/StatusFormat.cs
@@ -1,6 +1,8 @@
using gclo.Engine;
+using gclo.ViewModels;
using Microsoft.UI;
using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
namespace gclo;
@@ -51,6 +53,49 @@ public static class StatusFormat
public static Visibility VisibleIf(bool value)
=> value ? Visibility.Visible : Visibility.Collapsed;
+ /// Inverse of : collapsed when is true.
+ public static Visibility VisibleIfNot(bool value)
+ => value ? Visibility.Collapsed : Visibility.Visible;
+
+ /// Visible only once has text; hides empty preview lines.
+ public static Visibility VisibleIfNotEmpty(string value)
+ => string.IsNullOrEmpty(value) ? Visibility.Collapsed : Visibility.Visible;
+
+ /// InfoBar severity for a run outcome (None and Canceled read as informational).
+ public static InfoBarSeverity SeverityFor(RunResultKind kind) => kind switch
+ {
+ RunResultKind.Success => InfoBarSeverity.Success,
+ RunResultKind.PartialFailure => InfoBarSeverity.Warning,
+ RunResultKind.Error => InfoBarSeverity.Error,
+ _ => InfoBarSeverity.Informational, // Canceled, None
+ };
+
+ /// Short InfoBar title for a run outcome; empty for .
+ public static string TitleFor(RunResultKind kind) => kind switch
+ {
+ RunResultKind.Success => "Sync complete",
+ RunResultKind.PartialFailure => "Completed with failures",
+ RunResultKind.Canceled => "Canceled",
+ RunResultKind.Error => "Sync failed",
+ _ => "",
+ };
+
+ /// Repository count with explicit pluralization, e.g. "87 repositories".
+ public static string RepoCountText(int count)
+ => count == 1 ? "1 repository" : $"{count} repositories";
+
+ /// Toolbar selection summary, e.g. "12 of 87 selected".
+ public static string SelectionSummary(int selected, int total)
+ => $"{selected} of {total} selected";
+
+ /// Run progress shown beside the toolbar's progress bar, e.g. "34 of 87".
+ public static string RunProgressText(int completed, int total)
+ => $"{completed} of {total}";
+
+ /// Screen-reader name for a repository's progress bar in the active strip.
+ public static string ProgressAutomationName(string repoName)
+ => $"{repoName} sync progress";
+
/// Maps the persisted theme string to a XAML theme ("System"/unknown => Default).
public static ElementTheme ToElementTheme(string theme) => theme switch
{
@@ -58,7 +103,4 @@ public static class StatusFormat
"Dark" => ElementTheme.Dark,
_ => ElementTheme.Default,
};
-
- public static string Fraction(int completed, int total)
- => $"{completed} / {total}";
}
diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml
index 78a6b95..6706e10 100644
--- a/gclo/WorkspacePage.xaml
+++ b/gclo/WorkspacePage.xaml
@@ -11,313 +11,673 @@
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+ Padding="24"
+ Background="{ThemeResource LayerFillColorDefaultBrush}"
+ BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
+ BorderThickness="1"
+ CornerRadius="8">
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
+ Content="How to create a token"
+ NavigateUri="https://github.com/settings/tokens" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo/WorkspacePage.xaml.cs b/gclo/WorkspacePage.xaml.cs
index 142b879..fdf4d0e 100644
--- a/gclo/WorkspacePage.xaml.cs
+++ b/gclo/WorkspacePage.xaml.cs
@@ -1,16 +1,20 @@
using System;
+using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
namespace gclo
{
///
- /// One sync workspace: inputs, the two-phase Load/Sync actions, overall progress,
- /// and the per-repo table. Hosted by 's navigation shell —
+ /// One sync workspace in two visual states: a centered connect card until the first
+ /// successful load, then a connection chip plus the hero repository table with its
+ /// attached toolbar (filter, options, refresh, sync), the pinned active strip, and
+ /// the results InfoBar. Hosted by 's navigation shell —
/// one instance per saved account plus one for the pinned Quick Sync entry. Pages
/// (and their view models) are cached by the shell, so a running sync keeps going
/// while another workspace is displayed.
@@ -37,16 +41,13 @@ namespace gclo
InitializeComponent();
ViewModel.AnnouncementRequested += AnnounceToAssistiveTechnology;
- // LiveSetting alone does not announce: XAML never raises LiveRegionChanged
- // automatically, so each StatusText update must raise it in code-behind.
- ViewModel.PropertyChanged += (_, e) =>
- {
- if (e.PropertyName == nameof(WorkspaceViewModel.StatusText))
- {
- // Enqueued so the binding has pushed the new text before the event.
- DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
- }
- };
+ ViewModel.PropertyChanged += OnViewModelPropertyChanged;
+
+ // The chip's repo count, the toolbar's selection summary, and the
+ // empty-filter placeholder are set from code: they derive from collection
+ // counts, which raise no property change an x:Bind function could ride on.
+ ViewModel.Repos.CollectionChanged += (_, _) => UpdateDerivedTexts();
+ ViewModel.FilteredRepos.CollectionChanged += (_, _) => UpdateDerivedTexts();
// The view model stays UI-free: when ResolvePathsCommand needs the user's
// path-recovery choices, it calls back through here and the page answers
@@ -61,6 +62,11 @@ namespace gclo
{
TokenBox.Password = ViewModel.Token;
}
+
+ // SelectorBar starts with no selection; the view model's filter default is
+ // All, so select that item (the resulting SelectionChanged is a no-op set).
+ FilterSelectorBar.SelectedItem = FilterAllItem;
+ UpdateDerivedTexts();
}
///
@@ -71,28 +77,91 @@ namespace gclo
? "Create org subfolder"
: $"Create {organization.Trim()} subfolder";
- ///
- /// Header text for a sortable table column, with an arrow on the active sort column.
- ///
- public string SortHeader(string column, string? sortColumn, bool sortDescending)
- => column == sortColumn ? column + (sortDescending ? " ▼" : " ▲") : column;
+ /// True when this workspace is backed by a saved account (vs Quick Sync).
+ public bool IsAccountWorkspace => ViewModel.AccountId is not null;
///
- /// Visible while any repo in the table is Failed. The parameters are not read; they
- /// are the x:Bind re-evaluation triggers — CompletedCount changes whenever a repo
- /// finishes (including failures) and IsRunning changes when a run starts or ends,
- /// which together cover every point where the set of failed items can change.
+ /// Whether the edit flyout's connection fields accept input: Quick Sync follows
+ /// ; account workspaces are always
+ /// read-only here (their settings are edited in the account wizard).
///
- public Visibility AnyFailedVisibility(int completedCount, bool isRunning)
+ public bool ConnectFieldsEnabled(bool canEditInputs)
+ => canEditInputs && ViewModel.AccountId is null;
+
+ ///
+ /// Sort-direction arrow (visual only; the header button's automation name
+ /// carries the state) for the active sort column: chevron up = ascending.
+ ///
+ public string SortGlyph(string column, string? sortColumn, bool sortDescending)
+ => column == sortColumn ? (sortDescending ? "" : "") : "";
+
+ /// Shows the sort arrow only on the column the table is sorted by.
+ public Visibility SortGlyphVisibility(string column, string? sortColumn)
+ => column == sortColumn ? Visibility.Visible : Visibility.Collapsed;
+
+ ///
+ /// Composed automation name for a sortable column header, e.g.
+ /// "Name, sorted ascending" or "Branch, not sorted".
+ ///
+ public string SortAutomationName(string column, string? sortColumn, bool sortDescending)
+ => column != sortColumn
+ ? $"{column}, not sorted"
+ : sortDescending
+ ? $"{column}, sorted descending"
+ : $"{column}, sorted ascending";
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- foreach (RepoItemViewModel repo in ViewModel.Repos)
+ if (e.PropertyName == nameof(WorkspaceViewModel.StatusText))
{
- if (repo.Status == SyncStatus.Failed)
- {
- return Visibility.Visible;
- }
+ // LiveSetting alone does not announce: XAML never raises
+ // LiveRegionChanged automatically, so each StatusText update must raise
+ // it here. Enqueued so the binding has pushed the new text first.
+ DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
+ }
+ else if (e.PropertyName == nameof(WorkspaceViewModel.SelectedCount))
+ {
+ UpdateDerivedTexts();
+ }
+ }
+
+ ///
+ /// Refreshes the count-derived texts: the chip's repository count, the
+ /// toolbar's "N of M selected" summary, and the empty-filter placeholder.
+ ///
+ private void UpdateDerivedTexts()
+ {
+ ChipRepoCountText.Text = StatusFormat.RepoCountText(ViewModel.Repos.Count);
+ SelectionSummaryText.Text =
+ StatusFormat.SelectionSummary(ViewModel.SelectedCount, ViewModel.Repos.Count);
+ EmptyFilterText.Visibility =
+ ViewModel.FilteredRepos.Count == 0 && ViewModel.Repos.Count > 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+
+ private void FilterSelectorBar_SelectionChanged(
+ SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
+ {
+ if (sender.SelectedItem?.Tag is string tag && Enum.TryParse(tag, out RepoFilter filter))
+ {
+ ViewModel.Filter = filter;
+ }
+ }
+
+ // The chip's Edit link opens its attached flyout (HyperlinkButton has no
+ // Flyout property of its own).
+ private void EditButton_Click(object sender, RoutedEventArgs e)
+ => FlyoutBase.ShowAttachedFlyout((FrameworkElement)sender);
+
+ // PasswordBox has no reliable two-way binding, so the flyout's token box is
+ // synchronized on open; it then always shows the token in effect.
+ private void EditFlyout_Opening(object? sender, object e)
+ {
+ if (EditTokenBox.Password != ViewModel.Token)
+ {
+ EditTokenBox.Password = ViewModel.Token;
}
- return Visibility.Collapsed;
}
private void RaiseStatusLiveRegionChanged()
@@ -142,7 +211,8 @@ namespace gclo
}
// PasswordBox does not support reliable two-way x:Bind on Password;
- // mirror it into the view model by hand.
+ // mirror it into the view model by hand (shared by the connect card's box
+ // and the edit flyout's box).
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
{
ViewModel.Token = ((PasswordBox)sender).Password;