Workspace: error surfaces, stale-table guard, visible-row selection, O(1) progress updates (#30)

Verified UX findings:
- Load and organization-lookup failures now open a red, dismissable InfoBar
  with a Retry button (LoadErrorTitle/Message/Open), outside both page states
  so a failed first load and a failed refresh look the same. StatusText still
  carries the message for the live region.
- Editing the organization after a load marks the table stale
  (LoadedOrganization/IsTableStale/StaleTableMessage): a warning bar with
  "Reload repositories" appears and SyncCommand is blocked until a reload, so
  the chip can no longer promise one org while the rows (and the clone) are
  another's.
- A load that returns nothing shows an empty state naming the organization
  instead of a blank table with headers.
- The header checkbox acts on the VISIBLE rows and reflects them, so "filter by
  name, then select what you see" is the subset workflow; hidden rows keep
  their selection and still sync.
- Saving Settings no longer resets Quick Sync's parallelism unless the default
  itself changed (ApplySettings takes an explicit flag).

Verified performance findings:
- FilteredRepos is maintained incrementally on status transitions: one Insert
  or RemoveAt at the row's table position (binary search over a row->index
  map) instead of an O(n) rebuild with a Reset per report. Full rebuilds remain
  for filter, load, sort, and run start/end.
- Per-repo failure logging moved off the UI thread into a progress wrapper
  that logs on the engine's worker thread before marshaling.
- Header select-all and Retry-failed batch their selection changes: one
  recount and one CanExecute re-query per batch instead of per row.
- Sort drops the O(n^2) IndexOf/Move choreography (nothing binds Repos) for a
  plain O(n) reorder plus the one rebuild of the bound list.

Tests: 9 new WorkspaceViewModel tests (error surface open/close, lookup
failure, stale/unstale by edit and by reload, visible-row header semantics,
one CanExecute per batch, in-place membership updates with no Reset, sorted
insertion after a sort, worker-thread failure logging). 353 Engine tests,
coverage 100%, FlaUI e2e 8/8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
James MaesandClaude Fable 5.1 committed 2026-10-09 06:32:04 -05:00
1 parent fd6f6b286a
commit 64814d1411
5 files changed
+543 -50

No files matched your search

@@ -138,6 +138,11 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.IsLoadingRepos);
Assert.True(vm.LoadReposCommand.CanExecute(null));
Assert.False(vm.SyncCommand.CanExecute(null));
// The failure gets a real error surface, not just the caption live region (#30).
Assert.True(vm.LoadErrorOpen);
Assert.Equal("Could not load repositories", vm.LoadErrorTitle);
Assert.Equal("org not found", vm.LoadErrorMessage);
Assert.Null(vm.LoadedOrganization);
}
// ---------------------------------------------------------------- connect card state
@@ -778,8 +783,130 @@ public sealed class WorkspaceViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- load errors and stale tables (#30)
[Fact]
public async Task LoadRepos_StartingALoad_ClosesThePreviousLoadError()
{
_lister.ExceptionToThrow = new InvalidOperationException("boom");
var vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.True(vm.LoadErrorOpen);
_lister.ExceptionToThrow = null;
_lister.Repositories = Repos("alpha");
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.False(vm.LoadErrorOpen);
Assert.Equal("acme", vm.LoadedOrganization);
Assert.True(vm.HasLoadedRepos);
}
[Fact]
public async Task OrgLookupFailure_OpensTheErrorSurface_AndASuccessfulLookupClosesIt()
{
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(
new InvalidOperationException("GitHub rejected the token (401). Check the PAT."));
var vm = CreateViewModel();
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface");
Assert.Equal("Could not list organizations", vm.LoadErrorTitle);
Assert.Contains("401", vm.LoadErrorMessage);
Assert.Equal(vm.LoadErrorMessage, vm.StatusText);
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme"]);
vm.Token = "token-1234567890-fixed";
await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to succeed");
Assert.False(vm.LoadErrorOpen);
}
[Fact]
public async Task EditingTheOrganizationAfterALoad_MarksTheTableStale_AndBlocksSync()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
Assert.False(vm.IsTableStale);
Assert.Equal("", vm.StaleTableMessage);
Assert.True(vm.SyncCommand.CanExecute(null));
var raised = new List<string?>();
vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
vm.Organization = "acme-labs";
Assert.True(vm.IsTableStale);
Assert.Contains("'acme'", vm.StaleTableMessage);
Assert.Contains("'acme-labs'", vm.StaleTableMessage);
Assert.False(vm.SyncCommand.CanExecute(null), "syncing a stale table would clone the wrong org");
Assert.Contains(nameof(WorkspaceViewModel.IsTableStale), raised);
Assert.Contains(nameof(WorkspaceViewModel.StaleTableMessage), raised);
// Putting it back (case and whitespace are not a change) clears the state...
vm.Organization = " ACME ";
Assert.False(vm.IsTableStale);
Assert.True(vm.SyncCommand.CanExecute(null));
// ...and so does reloading under the new name.
vm.Organization = "acme-labs";
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.Equal("acme-labs", vm.LoadedOrganization);
Assert.False(vm.IsTableStale);
Assert.True(vm.SyncCommand.CanExecute(null));
}
[Fact]
public void BeforeAnyLoad_TheTableIsNeverStale()
{
var vm = CreateViewModel();
vm.Organization = "anything";
Assert.False(vm.IsTableStale);
}
// ---------------------------------------------------------------- selection
[Fact]
public async Task AllSelected_ActsOnVisibleRowsOnly_AndReflectsThem()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"));
vm.NameFilter = "al"; // alpha only
Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name));
Assert.True(vm.AllSelected);
vm.AllSelected = false;
// Only the visible row changed; hidden rows keep their selection and still sync.
Assert.False(vm.Repos[0].IsSelected);
Assert.True(vm.Repos[1].IsSelected);
Assert.True(vm.Repos[2].IsSelected);
Assert.Equal(2, vm.SelectedCount);
Assert.True(vm.SyncCommand.CanExecute(null));
// The header reflects the visible set as the filter changes.
vm.NameFilter = "";
Assert.False(vm.AllSelected);
vm.NameFilter = "br";
Assert.True(vm.AllSelected);
vm.AllSelected = true; // no-op on the visible row (already selected)
Assert.False(vm.Repos[0].IsSelected);
}
[Fact]
public async Task AllSelected_HeaderPush_ReQueriesSyncOncePerBatch()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta"));
int canExecuteRaised = 0;
vm.SyncCommand.CanExecuteChanged += (_, _) => canExecuteRaised++;
vm.AllSelected = false;
// One notification for the whole batch, not one per row (#30).
Assert.Equal(1, canExecuteRaised);
}
[Fact]
public async Task AllSelected_TogglesEveryRow_AndFollowsItemChanges()
{
@@ -849,6 +976,26 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.SortDescending); // direction resets on a new column
}
[Fact]
public async Task Sort_KeepsFilteredReposInTheNewOrder_AndMembershipUpdatesStillLandInPlace()
{
var vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("alpha"), Repo("bravo"));
vm.SortCommand.Execute("Name");
Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name));
// A progress-driven membership change after a sort must respect the sorted
// order (the position map is rebuilt by Sort).
vm.Filter = RepoFilter.Failed;
Assert.Empty(vm.FilteredRepos);
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) is "charlie" or "alpha"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
await vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(["alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name));
}
[Fact]
public async Task Sort_ByStatus_OrdersByLifecycle_AndIsStable()
{
@@ -899,6 +1046,93 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name));
}
[Fact]
public async Task ProgressTransitions_UpdateFilteredReposInPlace_WithoutAReset()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta"));
vm.Filter = RepoFilter.Pending;
Assert.Equal(4, vm.FilteredRepos.Count);
var actions = new List<System.Collections.Specialized.NotifyCollectionChangedAction>();
vm.FilteredRepos.CollectionChanged += (_, e) => actions.Add(e.Action);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = async (_, path, _, _, _) =>
{
if (Path.GetFileName(path) == "bravo")
{
await gate.Task;
}
};
var run = vm.SyncCommand.ExecuteAsync(null);
await WaitUntilAsync(() => vm.Repos[1].Status == SyncStatus.Cloning, "bravo to start");
// Rows leave Pending one by one as they start: removals, never a Reset.
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
Assert.DoesNotContain(vm.Repos[1], vm.FilteredRepos);
gate.SetResult();
await run;
Assert.Empty(vm.FilteredRepos);
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
// And a later transition back into the filtered set inserts in table order.
vm.Filter = RepoFilter.Failed;
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) is "delta" or "alpha"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
actions.Clear();
await vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(["alpha", "delta"], vm.FilteredRepos.Select(r => r.Name));
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
}
[Fact]
public async Task FailedReports_AreLoggedOnTheReportingThread_NotTheUiHandler()
{
var log = new ThreadRecordingLog();
_lister.Repositories = Repos("alpha");
var vm = new WorkspaceViewModel(_lister, _git, _orgs,
handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), log);
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"));
await vm.LoadReposCommand.ExecuteAsync(null);
int reportingThread = -1;
_git.CloneHandler = (_, _, _, _, _) =>
{
reportingThread = Environment.CurrentManagedThreadId;
return Task.FromException(new InvalidOperationException("boom"));
};
await vm.SyncCommand.ExecuteAsync(null);
var entry = Assert.Single(log.Errors, e => e.Message.StartsWith("alpha failed", StringComparison.Ordinal));
Assert.Contains("boom", entry.Message);
// The engine reports from the thread that ran the git operation; the fake's
// synchronous progress means the log call happened right there, before any
// UI-marshaled handler could have run.
Assert.Equal(reportingThread, entry.Thread);
}
private sealed class ThreadRecordingLog : IActivityLog
{
public List<(string Message, int Thread)> Errors { get; } = [];
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null)
=> Errors.Add((message, Environment.CurrentManagedThreadId));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
[Fact]
public async Task Filter_WithNoMatchingRows_LeavesFilteredReposEmpty()
{
+222 -37
View File
@@ -27,6 +27,16 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private readonly Dictionary<string, RepoItemViewModel> _itemsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _orgLoadCts;
/// <summary>
/// Position of every row in <see cref="Repos"/>; rebuilt on load and sort. Lets
/// progress-driven membership changes find a row's slot in <see cref="FilteredRepos"/>
/// by binary search instead of rebuilding the whole bound list (#30).
/// </summary>
private readonly Dictionary<RepoItemViewModel, int> _reposIndex = new();
/// <summary>Mirror of <see cref="FilteredRepos"/> for O(1) membership checks.</summary>
private readonly HashSet<RepoItemViewModel> _filteredSet = new();
/// <summary>Canceled on dispose so an in-flight path recovery dies with the workspace.</summary>
private readonly CancellationTokenSource _lifetimeCts = new();
@@ -91,6 +101,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
MaxConcurrency = AppSettings.DefaultConcurrency;
StatusText = "";
ResultMessage = "";
LoadErrorTitle = "";
LoadErrorMessage = "";
AllSelected = true;
CanEditInputs = true;
@@ -187,10 +199,56 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool CreateOrgSubfolder { get; set; }
/// <summary>Header checkbox state: setting it checks or unchecks every row.</summary>
/// <summary>
/// Header checkbox state: setting it checks or unchecks every VISIBLE row (the rows
/// in <see cref="FilteredRepos"/>), and it reads true when every visible row is
/// selected. With no filter active that is every row; with a filter it makes
/// "filter, then select what you see" the subset workflow (#30). Hidden rows keep
/// their selection and still sync.
/// </summary>
[ObservableProperty]
public partial bool AllSelected { get; set; }
/// <summary>
/// Organization whose repositories the table currently holds (trimmed, as loaded);
/// null before the first successful load.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTableStale))]
[NotifyPropertyChangedFor(nameof(StaleTableMessage))]
public partial string? LoadedOrganization { get; set; }
/// <summary>
/// True when <see cref="Organization"/> was edited after a load: the table still
/// shows the previous organization's rows while the chip and target path describe
/// the new one. Sync is blocked until a reload replaces the rows (#30).
/// </summary>
public bool IsTableStale
=> LoadedOrganization is not null
&& !string.Equals(Organization.Trim(), LoadedOrganization, StringComparison.OrdinalIgnoreCase);
/// <summary>One-line explanation shown while <see cref="IsTableStale"/>; empty otherwise.</summary>
public string StaleTableMessage
=> IsTableStale
? $"The table still shows '{LoadedOrganization}'. Reload to list '{Organization.Trim()}' before syncing."
: "";
/// <summary>Title of the error surface for a failed load or organization lookup.</summary>
[ObservableProperty]
public partial string LoadErrorTitle { get; set; }
/// <summary>Message of the most recent failed load or organization lookup; empty when none.</summary>
[ObservableProperty]
public partial string LoadErrorMessage { get; set; }
/// <summary>
/// True while the load-error surface is showing. Set by a failed load or
/// organization lookup, cleared when the next load starts or a lookup succeeds; the
/// page two-way binds it so the user can dismiss the bar (#30).
/// </summary>
[ObservableProperty]
public partial bool LoadErrorOpen { get; set; }
/// <summary>True once a sync run has finished (successfully, canceled, or faulted).</summary>
[ObservableProperty]
public partial bool HasCompletedRun { get; set; }
@@ -314,7 +372,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged();
partial void OnOrganizationChanged(string value) => NotifyTargetPathChanged();
partial void OnOrganizationChanged(string value)
{
NotifyTargetPathChanged();
OnPropertyChanged(nameof(IsTableStale));
OnPropertyChanged(nameof(StaleTableMessage));
SyncCommand.NotifyCanExecuteChanged();
}
/// <summary>Records a failed load or lookup on the error surface (and the live region).</summary>
private void ShowLoadError(string title, string message)
{
StatusText = message;
LoadErrorTitle = title;
LoadErrorMessage = message;
LoadErrorOpen = true;
}
partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged();
@@ -377,6 +450,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
Organization = organizationBeforeRefresh;
OnPropertyChanged(nameof(Organization));
LoadErrorOpen = false; // a lookup that works clears a stale lookup failure
// The production lister always lists the token's own account first, so a
// single entry means no organizations were visible.
StatusText = orgs.Count <= 1
@@ -391,7 +465,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
if (_orgLoadCts == cts)
{
StatusText = ex.Message;
ShowLoadError("Could not list organizations", ex.Message);
}
}
finally
@@ -442,7 +516,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_syncingSelection = true;
try
{
foreach (RepoItemViewModel item in Repos)
foreach (RepoItemViewModel item in FilteredRepos)
{
item.IsSelected = value;
}
@@ -469,18 +543,21 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
if (!_syncingSelection)
{
// A header push recomputes once after its loop instead of per row.
// A header push (or any other batch) recomputes once after its loop
// instead of per row — including the command re-query, whose CanSync
// scans every row (#30).
RecomputeSelectedCount();
UpdateAllSelectedFromItems();
SyncCommand.NotifyCanExecuteChanged();
}
SyncCommand.NotifyCanExecuteChanged();
}
private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected);
/// <summary>Header state follows the VISIBLE rows; see <see cref="AllSelected"/>.</summary>
private void UpdateAllSelectedFromItems()
{
bool all = Repos.All(r => r.IsSelected);
bool all = FilteredRepos.All(r => r.IsSelected);
if (AllSelected != all)
{
_syncingSelection = true;
@@ -510,6 +587,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
IsLoadingRepos = true;
ResetRunResult();
LoadErrorOpen = false;
try
{
string organization = Organization.Trim();
@@ -544,14 +622,18 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
SortColumn = null;
SortDescending = false;
_sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null;
UpdateAllSelectedFromItems();
RebuildReposIndex();
LoadedOrganization = organization;
HasLoadedRepos = true;
StatusText = $"{Repos.Count} repositories loaded.";
_log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'.");
}
catch (Exception ex)
{
StatusText = ex.Message;
// The message is good (the listers translate 401/404/rate limits); the
// surface must be too — an InfoBar with Retry, not caption text at the
// bottom of the window (#30).
ShowLoadError("Could not load repositories", ex.Message);
_log.Error($"Loading repositories failed: {ex.Message}", ex);
}
finally
@@ -559,7 +641,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
IsLoadingRepos = false;
NotifyTargetPathChanged();
RecomputeSelectedCount();
RebuildFilteredRepos();
RebuildFilteredRepos(); // also re-derives AllSelected from the visible rows
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
RecomputeHasFailedRepos();
@@ -572,6 +654,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& !IsTableStale
&& !string.IsNullOrWhiteSpace(TargetFolder)
&& Repos.Any(r => r.IsSelected);
@@ -597,6 +680,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_runSet = selected;
TotalCount = selected.Count;
RecomputeCompletedCount();
RebuildFilteredRepos(); // every selected row just went back to Queued
string targetRoot = EffectiveTargetRoot;
StatusText = $"Syncing {selected.Count} repositories...";
@@ -604,7 +688,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// Constructed on the UI thread: Progress<T> captures the WinUI
// SynchronizationContext, so HandleProgress always runs on the UI thread.
var progress = _progressFactory(HandleProgress);
// Failure logging is layered in front of it so the per-entry file I/O
// happens on the engine's worker thread, never on the dispatcher (#30).
var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log);
var request = new SyncRequest(
Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency);
@@ -676,10 +762,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[RelayCommand(CanExecute = nameof(CanRetryFailed))]
private async Task RetryFailedAsync()
{
foreach (RepoItemViewModel item in Repos)
// One batch: per-row recounts and command re-queries would be O(n^2) (#30).
_syncingSelection = true;
try
{
item.IsSelected = item.Status == SyncStatus.Failed;
foreach (RepoItemViewModel item in Repos)
{
item.IsSelected = item.Status == SyncStatus.Failed;
}
}
finally
{
_syncingSelection = false;
}
RecomputeSelectedCount();
UpdateAllSelectedFromItems();
SyncCommand.NotifyCanExecuteChanged();
// Executing SyncCommand itself keeps SyncCancelCommand working for retry runs.
await SyncCommand.ExecuteAsync(null);
}
@@ -744,6 +843,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
IsResolvingPaths = false;
}
RecomputeCompletedCount();
UpdateFilteredMembership(item);
RetryFailedCommand.NotifyCanExecuteChanged();
}
@@ -779,15 +879,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_ => OrderRepos(r => r.IsArchived, Comparer<bool>.Default),
};
// Move (rather than clear + re-add) so list controls keep row containers stable.
for (int target = 0; target < sorted.Count; target++)
// Nothing binds Repos (the table binds FilteredRepos), so a plain reorder is
// enough: O(n) adds instead of the O(n^2) IndexOf/Move choreography that
// used to run here, and the one rebuild below refreshes the bound list (#30).
Repos.Clear();
foreach (RepoItemViewModel item in sorted)
{
int current = Repos.IndexOf(sorted[target]);
if (current != target)
{
Repos.Move(current, target);
}
Repos.Add(item);
}
RebuildReposIndex();
RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order
}
@@ -842,8 +942,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
if (report.Status == SyncStatus.Failed)
{
_log.Error($"{report.RepoName} failed: {report.Error}");
// Failures never reach StatusText (which has a live region), so assistive
// Logged already by FailureLoggingProgress on the worker thread. Failures
// never reach StatusText (which has a live region), so assistive
// technology hears them only through this explicit channel.
AnnouncementRequested?.Invoke($"{report.RepoName} failed. {report.Error}");
}
@@ -852,20 +952,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
RecomputeCompletedCount();
}
else if (statusBefore != item.Status && Filter is RepoFilter.Active or RepoFilter.Pending)
if (statusBefore != item.Status)
{
// 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();
// Status-dependent filters follow every transition (a row entering Cloning
// leaves Pending and joins Active immediately). Only this one row can have
// changed membership, so it is inserted or removed in place — no O(n)
// rebuild, no Reset notification, no scroll jump (#30).
UpdateFilteredMembership(item);
}
}
/// <summary>
/// Logs failed reports on the thread that produced them (the engine's worker), then
/// forwards every report to the UI-marshaled progress. FileActivityLog opens,
/// appends, and closes the file per entry; a mass failure (revoked PAT, dropped
/// network) used to put a thousand of those on the UI thread (#30).
/// </summary>
private sealed class FailureLoggingProgress(IProgress<RepoProgress> inner, IActivityLog log)
: IProgress<RepoProgress>
{
public void Report(RepoProgress value)
{
if (value.Status == SyncStatus.Failed)
{
log.Error($"{value.RepoName} failed: {value.Error}");
}
inner.Report(value);
}
}
/// <summary>
/// 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.
/// the set is empty and the count stays 0. Also refreshes the failed-rows flag.
/// </summary>
private void RecomputeCompletedCount()
{
@@ -879,7 +998,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
CompletedCount = completed;
RecomputeHasFailedRepos();
RebuildFilteredRepos();
}
private void RecomputeHasFailedRepos()
@@ -887,24 +1005,91 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// ---------------------------------------------------------------- filtering
/// <summary>Refreshes the row -> position map after <see cref="Repos"/> was reloaded or reordered.</summary>
private void RebuildReposIndex()
{
_reposIndex.Clear();
for (int i = 0; i < Repos.Count; i++)
{
_reposIndex[Repos[i]] = i;
}
}
/// <summary>
/// Rebuilds <see cref="FilteredRepos"/> from <see cref="Repos"/>, 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.
/// table's current sort order, then re-derives the header checkbox from the
/// visible rows. Used when the filter, the table contents, or the sort order
/// change; single-row status transitions go through
/// <see cref="UpdateFilteredMembership"/> instead. A no-op when the visible set
/// is already correct, so list controls keep their scroll position.
/// </summary>
private void RebuildFilteredRepos()
{
List<RepoItemViewModel> desired = Repos.Where(MatchesFilter).ToList();
if (desired.SequenceEqual(FilteredRepos))
if (!desired.SequenceEqual(FilteredRepos))
{
FilteredRepos.Clear();
_filteredSet.Clear();
foreach (RepoItemViewModel repo in desired)
{
FilteredRepos.Add(repo);
_filteredSet.Add(repo);
}
}
UpdateAllSelectedFromItems();
}
/// <summary>
/// Applies one row's current filter match to <see cref="FilteredRepos"/>: inserts it
/// at its table position (binary search on <see cref="_reposIndex"/>) when it became
/// visible, removes it when it became hidden, and does nothing when its membership
/// did not change. O(log n) instead of the former O(n) rebuild per progress report.
/// </summary>
private void UpdateFilteredMembership(RepoItemViewModel item)
{
bool visible = MatchesFilter(item);
bool wasVisible = _filteredSet.Contains(item);
if (visible == wasVisible)
{
return;
}
FilteredRepos.Clear();
foreach (RepoItemViewModel repo in desired)
int slot = FindFilteredSlot(item);
if (visible)
{
FilteredRepos.Add(repo);
FilteredRepos.Insert(slot, item);
_filteredSet.Add(item);
}
else
{
FilteredRepos.RemoveAt(slot);
_filteredSet.Remove(item);
}
UpdateAllSelectedFromItems();
}
/// <summary>
/// Index in <see cref="FilteredRepos"/> where <paramref name="item"/> sits (when
/// present) or belongs (when absent), by its position in <see cref="Repos"/>.
/// </summary>
private int FindFilteredSlot(RepoItemViewModel item)
{
int position = _reposIndex[item];
int low = 0;
int high = FilteredRepos.Count;
while (low < high)
{
int mid = (low + high) / 2;
if (_reposIndex[FilteredRepos[mid]] < position)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private bool MatchesFilter(RepoItemViewModel repo)
+17 -6
View File
@@ -90,7 +90,7 @@ namespace gclo
// Create and show Quick Sync before selecting it, so NavigationView.Content
// is never empty regardless of when SelectionChanged fires.
ShowWorkspace(Guid.Empty);
ApplySettings();
ApplySettings(seedQuickSyncConcurrency: true);
WorkspaceNav.SelectedItem = QuickSyncNavItem;
// AppWindow.Resize takes physical pixels; scale by the monitor DPI so the
@@ -134,15 +134,22 @@ namespace gclo
}
}
private void ApplySettings()
/// <summary>
/// Applies the theme and seeds the ad-hoc Quick Sync workspace from the settings
/// defaults (account workspaces carry their own configuration). The target
/// folder is only filled when blank; the parallelism default is pushed only
/// when <paramref name="seedQuickSyncConcurrency"/> — at startup, and after a
/// Settings save that actually changed it — so saving an unrelated setting
/// (theme, splash) cannot silently reset a value the user tuned in the
/// workspace's Options flyout (#30).
/// </summary>
private void ApplySettings(bool seedQuickSyncConcurrency)
{
if (Content is FrameworkElement root)
{
root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme);
}
// Settings defaults only seed the ad-hoc Quick Sync workspace; account
// workspaces carry their own configuration.
if (_workspaces.TryGetValue(Guid.Empty, out var quickSync))
{
WorkspaceViewModel viewModel = quickSync.ViewModel;
@@ -151,7 +158,10 @@ namespace gclo
{
viewModel.TargetFolder = _settings.DefaultTargetFolder;
}
viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
if (seedQuickSyncConcurrency)
{
viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
}
}
}
@@ -738,10 +748,11 @@ namespace gclo
{
XamlRoot = Content.XamlRoot,
};
int concurrencyBefore = _settings.DefaultMaxConcurrency;
if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
dialog.ApplyAndSave();
ApplySettings();
ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore);
}
}
+61 -6
View File
@@ -31,6 +31,7 @@
<Grid Padding="20" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
@@ -57,10 +58,29 @@
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<!-- Failed load / organization lookup. Lives outside both states so a first
load that fails (connect card still showing) and a refresh that fails
(table showing) get the same red, dismissable surface with a Retry — not
caption text at the bottom of the window. -->
<InfoBar
Grid.Row="0"
AutomationProperties.AutomationId="LoadErrorBar"
Severity="Error"
IsOpen="{x:Bind ViewModel.LoadErrorOpen, Mode=TwoWay}"
Title="{x:Bind ViewModel.LoadErrorTitle, Mode=OneWay}"
Message="{x:Bind ViewModel.LoadErrorMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="LoadErrorRetryButton"
Content="Retry"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<!-- ============================================================ State 1
Connect card, shown until the first successful load in this workspace. -->
<ScrollViewer
Grid.Row="0"
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Border
@@ -177,10 +197,11 @@
<!-- ============================================================ State 2
Connection chip + hero table card, once repositories have loaded. -->
<Grid
Grid.Row="0"
Grid.Row="1"
RowSpacing="12"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@@ -314,9 +335,28 @@
</StackPanel>
</Border>
<!-- The organization was edited after the load: the rows no longer match the
chip and target path, so syncing is blocked until a reload. Not closable;
it goes away by reloading (or by putting the organization back). -->
<InfoBar
Grid.Row="1"
AutomationProperties.AutomationId="StaleTableBar"
Severity="Warning"
IsClosable="False"
IsOpen="{x:Bind ViewModel.IsTableStale, Mode=OneWay}"
Title="Organization changed"
Message="{x:Bind ViewModel.StaleTableMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="StaleTableReloadButton"
Content="Reload repositories"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<!-- Hero table card: toolbar, active strip, results InfoBar, header, rows. -->
<Grid
Grid.Row="1"
Grid.Row="2"
Padding="12"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
@@ -563,8 +603,8 @@
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
ToolTipService.ToolTip="Select all"
AutomationProperties.Name="Select all repositories"
ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)"
AutomationProperties.Name="Select all visible repositories"
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
<Grid Grid.Column="1">
@@ -875,6 +915,21 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="No repositories match this filter."
Visibility="Collapsed" />
<!-- Shown when a load succeeded but returned nothing: a wrong name, an
empty org, or a token that cannot see its repositories all list as
empty rather than failing, and a blank table explains none of them. -->
<TextBlock
x:Name="EmptyTableText"
AutomationProperties.AutomationId="EmptyTableText"
Grid.Row="4"
MaxWidth="480"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed" />
</Grid>
</Grid>
@@ -883,7 +938,7 @@
<TextBlock
x:Name="StatusTextBlock"
AutomationProperties.AutomationId="StatusTextBlock"
Grid.Row="1"
Grid.Row="2"
AutomationProperties.LiveSetting="Polite"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+9 -1
View File
@@ -119,7 +119,8 @@ namespace gclo
// it here. Enqueued so the binding has pushed the new text first.
DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
}
else if (e.PropertyName == nameof(WorkspaceViewModel.SelectedCount))
else if (e.PropertyName is nameof(WorkspaceViewModel.SelectedCount)
or nameof(WorkspaceViewModel.LoadedOrganization))
{
UpdateDerivedTexts();
}
@@ -152,6 +153,13 @@ namespace gclo
ViewModel.FilteredRepos.Count == 0 && ViewModel.Repos.Count > 0
? Visibility.Visible
: Visibility.Collapsed;
bool loadedNothing = ViewModel.HasLoadedRepos && ViewModel.Repos.Count == 0;
EmptyTableText.Text = loadedNothing
? $"No repositories found in '{ViewModel.LoadedOrganization}'. "
+ "Check the name, or make sure the token can see its repositories."
: "";
EmptyTableText.Visibility = loadedNothing ? Visibility.Visible : Visibility.Collapsed;
}
private void FilterSelectorBar_SelectionChanged(