From a23fa3579d773444add3a22a285ba1193f3c911a Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 4 Jul 2026 16:34:08 -0500 Subject: [PATCH] Phase 3: workspace redesign (#16, reconciles #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Two-state workspace: centered connect card (token + how-to link, org, destination, live placement preview) until the first load; then a connection chip (org, repo count, destination, Edit flyout) over a hero table card on a proper content layer - Table toolbar: filter chips (All/Active/Failed/Pending), selection summary, options flyout (parallel clones, org subfolder), Refresh, and one accent CTA labeled with the selection ("Sync 12 repos"); swaps to run-scoped progress + Cancel while running - Pinned active strip: the repos in flight right now, bounded by the parallelism setting — the table never moves under the user - Results InfoBar (severity-aware) with Retry failed + Open folder; the permanent action row, status row, and preview row are gone - Run-scoped progress (#22): the bar describes the run's selection - Accessibility & polish: access keys, Ctrl+R/Ctrl+Enter/Esc, sort state in accessible names with visual-only arrows, input locking while busy, minimum window size, "path(s)" pluralization, ellipsis and microcopy fixes - Adversarial review pre-ship: 4 confirmed findings fixed (stale Active/Pending filters during runs, path-recovery mutual exclusion with sync/reload incl. real cancellation on dispose, sync-during- reload state destruction), 13 refuted - 28 new tests (174 total) Co-Authored-By: Claude Fable 5 --- docs/CLI.md | 2 +- .../InvalidRepositoryPathsExceptionTests.cs | 32 + gclo.Engine.Tests/WorkspaceViewModelTests.cs | 494 ++++++++- .../InvalidRepositoryPathsException.cs | 3 +- gclo.ViewModels/RepoFilter.cs | 17 + gclo.ViewModels/RunResultKind.cs | 20 + gclo.ViewModels/WorkspaceViewModel.cs | 214 +++- gclo/MainWindow.xaml | 18 +- gclo/MainWindow.xaml.cs | 16 + gclo/StatusFormat.cs | 48 +- gclo/WorkspacePage.xaml | 962 ++++++++++++------ gclo/WorkspacePage.xaml.cs | 128 ++- 12 files changed, 1603 insertions(+), 351 deletions(-) create mode 100644 gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs create mode 100644 gclo.ViewModels/RepoFilter.cs create mode 100644 gclo.ViewModels/RunResultKind.cs 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 @@ - - - - - - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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; -- 2.54.0