Phase 3: workspace redesign #27

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-04 21:39:14 +00:00
12 changed files with 1603 additions and 351 deletions

No files matched your search

+1 -1
View File
@@ -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)
```
@@ -0,0 +1,32 @@
namespace gclo.Engine.Tests;
/// <summary>Pins the user-facing message of <see cref="InvalidRepositoryPathsException"/>.</summary>
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);
}
}
+492 -2
View File
@@ -21,6 +21,17 @@ public sealed class WorkspaceViewModelTests
public void Report(RepoProgress value) => handler(value);
}
/// <summary>A lister that blocks until the test releases <see cref="Gate"/>.</summary>
private sealed class GatedLister : IRepositoryLister
{
public TaskCompletionSource<IReadOnlyList<RepoDescriptor>> Gate { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<IReadOnlyList<RepoDescriptor>> 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<InvalidPathInfo> 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<PathRecovery?>(
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
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<string?>();
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
}
/// <summary>A lister whose calls each await their own gate, in order.</summary>
private sealed class SequencedGatedLister : IRepositoryLister
{
public Queue<TaskCompletionSource<IReadOnlyList<RepoDescriptor>>> Gates { get; } = new();
public Task<IReadOnlyList<RepoDescriptor>> 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<IReadOnlyList<RepoDescriptor>>(
TaskCreationOptions.RunContinuationsAsynchronously);
var second = new TaskCompletionSource<IReadOnlyList<RepoDescriptor>>(
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<PathRecovery?>(
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
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);
}
}
@@ -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.";
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace gclo.ViewModels;
/// <summary>Which repository rows the workspace table shows.</summary>
public enum RepoFilter
{
/// <summary>Every loaded repository.</summary>
All,
/// <summary>Rows with a git operation in flight (cloning or pulling).</summary>
Active,
/// <summary>Rows whose last run failed.</summary>
Failed,
/// <summary>Rows still queued and waiting for a worker.</summary>
Pending,
}
+20
View File
@@ -0,0 +1,20 @@
namespace gclo.ViewModels;
/// <summary>Outcome of the most recent sync run; drives the results InfoBar's severity.</summary>
public enum RunResultKind
{
/// <summary>No result to show: nothing has run yet, or a new run/load reset it.</summary>
None,
/// <summary>The run finished and every repository succeeded.</summary>
Success,
/// <summary>The run finished but at least one repository failed.</summary>
PartialFailure,
/// <summary>The run was canceled before every repository finished.</summary>
Canceled,
/// <summary>The run itself faulted (for example, the target root could not be created).</summary>
Error,
}
+209 -5
View File
@@ -27,12 +27,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private readonly Dictionary<string, RepoItemViewModel> _itemsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _orgLoadCts;
/// <summary>Canceled on dispose so an in-flight path recovery dies with the workspace.</summary>
private readonly CancellationTokenSource _lifetimeCts = new();
/// <summary>Breaks the AllSelected &lt;-&gt; item.IsSelected feedback loop while one side updates the other.</summary>
private bool _syncingSelection;
/// <summary>First repository name from the last load; makes <see cref="TargetPreview"/> concrete.</summary>
private string? _sampleRepoName;
/// <summary>
/// Rows captured for the in-flight (or most recent) sync run. Progress is scoped to
/// this set (#22): <see cref="TotalCount"/>/<see cref="CompletedCount"/> describe the
/// run, not the whole table. Empty until the first run and reset by each load.
/// </summary>
private IReadOnlyList<RepoItemViewModel> _runSet = [];
/// <summary>
/// Production dependencies by default; pass fakes for testing. The default progress
/// factory is <see cref="Progress{T}"/>, 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<RepoItemViewModel> Repos { get; } = new();
/// <summary>
/// The rows the table shows: the subset of <see cref="Repos"/> matching
/// <see cref="Filter"/>, 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.
/// </summary>
public ObservableCollection<RepoItemViewModel> FilteredRepos { get; } = new();
/// <summary>
/// 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
/// <see cref="MaxConcurrency"/>. Cleared when a run starts and again when it ends.
/// </summary>
public ObservableCollection<RepoItemViewModel> ActiveRepos { get; } = new();
/// <summary>Organizations discovered from the current token; feeds the org dropdown.</summary>
public ObservableCollection<string> Organizations { get; } = new();
@@ -127,8 +153,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>True while <see cref="LoadReposCommand"/> is listing repositories.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
[NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
public partial bool IsLoadingRepos { get; set; }
/// <summary>
/// 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.
/// </summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
[NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
[NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))]
public partial bool IsResolvingPaths { get; set; }
/// <summary>When set, repositories are placed under TargetFolder\Organization.</summary>
[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; }
/// <summary>Which rows the repository table shows; defaults to <see cref="RepoFilter.All"/>.</summary>
[ObservableProperty]
public partial RepoFilter Filter { get; set; }
/// <summary>Number of rows currently selected for the next sync.</summary>
[ObservableProperty]
public partial int SelectedCount { get; set; }
/// <summary>Caption for the primary sync button, pluralized for the current selection.</summary>
public string SyncButtonLabel => SelectedCount == 1 ? "Sync 1 repo" : $"Sync {SelectedCount} repos";
/// <summary>True when the connect inputs may be edited: no load and no run in flight.</summary>
[ObservableProperty]
public partial bool CanEditInputs { get; set; }
/// <summary>
/// True once this workspace has completed its first successful repository load;
/// drives the connect-card vs workspace visibility switch. Never reset.
/// </summary>
[ObservableProperty]
public partial bool HasLoadedRepos { get; set; }
/// <summary>Outcome of the most recent run; maps to the results InfoBar's severity.</summary>
[ObservableProperty]
public partial RunResultKind ResultKind { get; set; }
/// <summary>Summary or error line of the most recent run, shown in the results InfoBar.</summary>
[ObservableProperty]
public partial string ResultMessage { get; set; }
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>Clears the previous run's result surface; called when a new run or load starts.</summary>
private void ResetRunResult()
{
ResultOpen = false;
ResultKind = RunResultKind.None;
ResultMessage = "";
}
/// <summary>Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization.</summary>
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();
/// <summary>Stops and releases the in-flight org lookup, if any.</summary>
/// <summary>Stops the in-flight org lookup and path recovery, if any.</summary>
public void Dispose()
{
_orgLoadCts?.Cancel();
_orgLoadCts?.Dispose();
_orgLoadCts = null;
_lifetimeCts.Cancel();
_lifetimeCts.Dispose();
}
/// <summary>Debounced: each token edit cancels the previous lookup.</summary>
@@ -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);
/// <summary>Selects exactly the failed repositories and runs the same sync path again.</summary>
[RelayCommand(CanExecute = nameof(CanRetryFailed))]
@@ -514,7 +648,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
public Func<RepoItemViewModel, Task<PathRecovery?>>? RecoveryInteraction { get; set; }
private bool CanResolvePaths(RepoItemViewModel item)
=> item is not null && item.HasPathIssue && !IsRunning;
=> item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths;
/// <summary>
/// Asks the view (via <see cref="RecoveryInteraction"/>) 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
}
/// <summary>OrderBy/OrderByDescending are both stable: equal keys keep their current order.</summary>
@@ -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();
}
}
/// <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.
/// </summary>
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
/// <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.
/// </summary>
private void RebuildFilteredRepos()
{
List<RepoItemViewModel> 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,
};
}
+9 -9
View File
@@ -20,19 +20,19 @@
</Grid.RowDefinitions>
<MenuBar Grid.Row="0">
<MenuBarItem Title="File">
<MenuFlyoutItem Text="Settings…" Click="SettingsMenuItem_Click" />
<MenuBarItem Title="File" AccessKey="F">
<MenuFlyoutItem Text="Settings…" AccessKey="S" Click="SettingsMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="Exit" Click="ExitMenuItem_Click" />
<MenuFlyoutItem Text="Exit" AccessKey="X" Click="ExitMenuItem_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
<MenuFlyoutItem Text="Activity log…" Click="ActivityLogMenuItem_Click" />
<MenuBarItem Title="View" AccessKey="V">
<MenuFlyoutItem Text="Activity log…" AccessKey="A" Click="ActivityLogMenuItem_Click" />
</MenuBarItem>
<MenuBarItem Title="Help">
<MenuFlyoutItem Text="View on GitHub" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Check for updates…" Click="CheckForUpdatesMenuItem_Click" />
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="About gclo…" Click="AboutMenuItem_Click">
<MenuFlyoutItem Text="About gclo…" AccessKey="A" Click="AboutMenuItem_Click">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="F1" />
</MenuFlyoutItem.KeyboardAccelerators>
+16
View File
@@ -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();
}
+45 -3
View File
@@ -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;
/// <summary>Inverse of <see cref="VisibleIf"/>: collapsed when <paramref name="value"/> is true.</summary>
public static Visibility VisibleIfNot(bool value)
=> value ? Visibility.Collapsed : Visibility.Visible;
/// <summary>Visible only once <paramref name="value"/> has text; hides empty preview lines.</summary>
public static Visibility VisibleIfNotEmpty(string value)
=> string.IsNullOrEmpty(value) ? Visibility.Collapsed : Visibility.Visible;
/// <summary>InfoBar severity for a run outcome (None and Canceled read as informational).</summary>
public static InfoBarSeverity SeverityFor(RunResultKind kind) => kind switch
{
RunResultKind.Success => InfoBarSeverity.Success,
RunResultKind.PartialFailure => InfoBarSeverity.Warning,
RunResultKind.Error => InfoBarSeverity.Error,
_ => InfoBarSeverity.Informational, // Canceled, None
};
/// <summary>Short InfoBar title for a run outcome; empty for <see cref="RunResultKind.None"/>.</summary>
public static string TitleFor(RunResultKind kind) => kind switch
{
RunResultKind.Success => "Sync complete",
RunResultKind.PartialFailure => "Completed with failures",
RunResultKind.Canceled => "Canceled",
RunResultKind.Error => "Sync failed",
_ => "",
};
/// <summary>Repository count with explicit pluralization, e.g. "87 repositories".</summary>
public static string RepoCountText(int count)
=> count == 1 ? "1 repository" : $"{count} repositories";
/// <summary>Toolbar selection summary, e.g. "12 of 87 selected".</summary>
public static string SelectionSummary(int selected, int total)
=> $"{selected} of {total} selected";
/// <summary>Run progress shown beside the toolbar's progress bar, e.g. "34 of 87".</summary>
public static string RunProgressText(int completed, int total)
=> $"{completed} of {total}";
/// <summary>Screen-reader name for a repository's progress bar in the active strip.</summary>
public static string ProgressAutomationName(string repoName)
=> $"{repoName} sync progress";
/// <summary>Maps the persisted theme string to a XAML theme ("System"/unknown => Default).</summary>
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}";
}
+661 -301
View File
@@ -11,313 +11,673 @@
<Grid Padding="20" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Token first: it determines which organizations the dropdown can offer -->
<Grid Grid.Row="0" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<PasswordBox
x:Name="TokenBox"
Grid.Column="0"
Header="Personal access token"
PlaceholderText="ghp_..."
PasswordChanged="TokenBox_PasswordChanged" />
<ComboBox
Grid.Column="1"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="paste a token first, or type an org"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="2"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<!-- Target folder + subfolder option + concurrency -->
<Grid Grid.Row="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Content="Browse..."
Click="BrowseButton_Click" />
<CheckBox
Grid.Column="2"
MinWidth="0"
VerticalAlignment="Bottom"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<NumberBox
Grid.Column="3"
Header="Parallel"
Minimum="1"
Maximum="64"
SpinButtonPlacementMode="Inline"
Value="{x:Bind ViewModel.MaxConcurrencyValue, Mode=TwoWay}" />
</Grid>
<!-- Where the clones will actually land -->
<TextBlock
Grid.Row="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap">
<Run Text="Repositories will be created like:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
<!-- Actions -->
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="12">
<Button
MinWidth="110"
Style="{StaticResource AccentButtonStyle}"
Content="Load repos"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
<ProgressRing
Width="20"
Height="20"
<!-- ============================================================ State 1
Connect card, shown until the first successful load in this workspace. -->
<ScrollViewer
Grid.Row="0"
VerticalScrollBarVisibility="Auto"
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Border
MaxWidth="480"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="Loading repositories"
IsActive="{x:Bind ViewModel.IsLoadingRepos, Mode=OneWay}" />
<Button
MinWidth="110"
Content="Sync selected"
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}" />
<Button
MinWidth="90"
Content="Cancel"
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}" />
<Button
MinWidth="110"
Content="Retry failed"
Command="{x:Bind ViewModel.RetryFailedCommand, Mode=OneTime}"
Visibility="{x:Bind AnyFailedVisibility(ViewModel.CompletedCount, ViewModel.IsRunning), Mode=OneWay}" />
<Button
MinWidth="110"
Content="Open folder"
Click="OpenFolderButton_Click"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasCompletedRun), Mode=OneWay}" />
</StackPanel>
Padding="24"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="12">
<TextBlock Style="{ThemeResource SubtitleTextBlockStyle}" Text="Connect to GitHub" />
<!-- Overall progress -->
<Grid Grid.Row="4" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Spacing="4">
<ProgressBar
AutomationProperties.Name="Overall sync progress"
Maximum="{x:Bind ViewModel.TotalCount, Mode=OneWay}"
Value="{x:Bind ViewModel.CompletedCount, Mode=OneWay}" />
<TextBlock
x:Name="StatusTextBlock"
AutomationProperties.LiveSetting="Polite"
Text="{x:Bind ViewModel.StatusText, Mode=OneWay}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock
Grid.Column="1"
VerticalAlignment="Top"
Text="{x:Bind local:StatusFormat.Fraction(ViewModel.CompletedCount, ViewModel.TotalCount), Mode=OneWay}" />
</Grid>
<!-- Table header: fixed column widths mirrored by the row template below -->
<Grid Grid.Row="5" Padding="12,0,12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Column="0"
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
ToolTipService.ToolTip="Select all"
AutomationProperties.Name="Select all repositories"
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
<Button
Grid.Column="1"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
FontWeight="SemiBold"
Content="{x:Bind SortHeader('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Name" />
<Button
Grid.Column="2"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
FontWeight="SemiBold"
Content="{x:Bind SortHeader('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Status" />
<Button
Grid.Column="3"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
FontWeight="SemiBold"
Content="{x:Bind SortHeader('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Branch" />
<Button
Grid.Column="4"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
FontWeight="SemiBold"
Content="{x:Bind SortHeader('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Archived" />
</Grid>
<!-- Per-repo table body. The ListView carries the view model as DataContext so
the row template's Resolve link can reach WorkspaceViewModel.ResolvePathsCommand
via ElementName (x:Bind inside the template is scoped to the row item). -->
<ListView
x:Name="RepoListView"
Grid.Row="6"
DataContext="{x:Bind ViewModel, Mode=OneTime}"
ItemsSource="{x:Bind ViewModel.Repos, Mode=OneTime}"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="12,0,12,0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:RepoItemViewModel">
<Grid Padding="0,6,0,6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Row="0"
Grid.Column="0"
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
<TextBlock
Grid.Row="0"
Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
Text="{x:Bind Name, Mode=OneTime}" />
<StackPanel
Grid.Row="0"
Grid.Column="2"
Margin="0,0,12,0"
VerticalAlignment="Center"
Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon
FontSize="12"
VerticalAlignment="Center"
Glyph="{x:Bind local:StatusFormat.GlyphFor(Status), Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
<TextBlock
Text="{x:Bind StatusText, Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
</StackPanel>
<ProgressBar
Maximum="1"
Value="{x:Bind ProgressValue, Mode=OneWay}"
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ShowProgress), Mode=OneWay}" />
</StackPanel>
<TextBlock
Grid.Row="0"
Grid.Column="3"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind BranchText, Mode=OneTime}" />
<TextBlock
Grid.Row="0"
Grid.Column="4"
VerticalAlignment="Center"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Archived"
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsArchived), Mode=OneTime}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="5"
TextWrapping="Wrap"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Text="{x:Bind Error, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
<!-- ContentControl: panels have no IsEnabled; a disabled Control disables its subtree. -->
<ContentControl
HorizontalContentAlignment="Stretch"
IsEnabled="{x:Bind ViewModel.CanEditInputs, Mode=OneWay}"
IsTabStop="False">
<StackPanel Spacing="12">
<PasswordBox
x:Name="TokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="5"
Padding="0"
HorizontalAlignment="Left"
AutomationProperties.Name="{x:Bind local:StatusFormat.ResolveAutomationName(Name), Mode=OneTime}"
Content="Resolve…"
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoListView}"
CommandParameter="{x:Bind}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasPathIssue), Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="1"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ContentControl>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
MinWidth="140"
Style="{StaticResource AccentButtonStyle}"
Content="Load repositories"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="R" Modifiers="Control" />
</Button.KeyboardAccelerators>
</Button>
<ProgressRing
Width="20"
Height="20"
VerticalAlignment="Center"
AutomationProperties.Name="Loading repositories"
IsActive="{x:Bind ViewModel.IsLoadingRepos, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>
<!-- ============================================================ State 2
Connection chip + hero table card, once repositories have loaded. -->
<Grid
Grid.Row="0"
RowSpacing="12"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Connection chip: what this workspace is connected to, and where clones land. -->
<Border
Grid.Row="0"
HorizontalAlignment="Left"
Padding="12,6"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="16">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
FontSize="14"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Glyph="&#xE77B;" />
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.Organization, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="·" />
<TextBlock
x:Name="ChipRepoCountText"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<TextBlock
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="·" />
<TextBlock
MaxWidth="280"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"
ToolTipService.ToolTip="{x:Bind ViewModel.EffectiveTargetRoot, Mode=OneWay}"
Text="{x:Bind ViewModel.EffectiveTargetRoot, Mode=OneWay}" />
<HyperlinkButton
Padding="4,0"
VerticalAlignment="Center"
AutomationProperties.Name="Edit connection"
Content="Edit"
Click="EditButton_Click">
<FlyoutBase.AttachedFlyout>
<Flyout x:Name="EditFlyout" Placement="Bottom" Opening="EditFlyout_Opening">
<StackPanel Width="360" Spacing="12">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Account settings are edited in the account wizard (next update)."
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsAccountWorkspace), Mode=OneTime}" />
<ContentControl
HorizontalContentAlignment="Stretch"
IsEnabled="{x:Bind ConnectFieldsEnabled(ViewModel.CanEditInputs), Mode=OneWay}"
IsTabStop="False">
<StackPanel Spacing="12">
<PasswordBox
x:Name="EditTokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="1"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ContentControl>
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</HyperlinkButton>
</StackPanel>
</Border>
<!-- Hero table card: toolbar, active strip, results InfoBar, header, rows. -->
<Grid
Grid.Row="1"
Padding="12"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Toolbar: filter | selection summary | spacer | options / refresh / sync,
with the right side swapping to live progress + Cancel while running. -->
<Grid Grid.Row="0" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<SelectorBar
x:Name="FilterSelectorBar"
Grid.Column="0"
VerticalAlignment="Center"
SelectionChanged="FilterSelectorBar_SelectionChanged">
<SelectorBarItem x:Name="FilterAllItem" Text="All" Tag="All" />
<SelectorBarItem Text="Active" Tag="Active" />
<SelectorBarItem Text="Failed" Tag="Failed" />
<SelectorBarItem Text="Pending" Tag="Pending" />
</SelectorBar>
<TextBlock
x:Name="SelectionSummaryText"
Grid.Column="1"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<StackPanel
Grid.Column="3"
Orientation="Horizontal"
Spacing="8"
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}">
<Button
x:Name="OptionsButton"
AutomationProperties.Name="Sync options"
ToolTipService.ToolTip="Sync options">
<FontIcon FontSize="16" Glyph="&#xE713;" />
<Button.Flyout>
<Flyout Placement="Bottom">
<ContentControl
HorizontalContentAlignment="Stretch"
IsEnabled="{x:Bind ViewModel.CanEditInputs, Mode=OneWay}"
IsTabStop="False">
<StackPanel Width="320" Spacing="12">
<NumberBox
Header="Parallel clones"
Minimum="1"
Maximum="64"
SpinButtonPlacementMode="Inline"
ToolTipService.ToolTip="How many repositories are cloned or pulled at the same time"
Value="{x:Bind ViewModel.MaxConcurrencyValue, Mode=TwoWay}" />
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ContentControl>
</Flyout>
</Button.Flyout>
</Button>
<Button
Content="Refresh"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="R" Modifiers="Control" />
</Button.KeyboardAccelerators>
</Button>
<Button
MinWidth="120"
Style="{StaticResource AccentButtonStyle}"
Content="{x:Bind ViewModel.SyncButtonLabel, Mode=OneWay}"
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="Enter" Modifiers="Control" />
</Button.KeyboardAccelerators>
</Button>
</StackPanel>
<StackPanel
Grid.Column="3"
Orientation="Horizontal"
Spacing="8"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsRunning), Mode=OneWay}">
<ProgressBar
Width="160"
VerticalAlignment="Center"
AutomationProperties.Name="Overall sync progress"
Maximum="{x:Bind ViewModel.TotalCount, Mode=OneWay}"
Value="{x:Bind ViewModel.CompletedCount, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind local:StatusFormat.RunProgressText(ViewModel.CompletedCount, ViewModel.TotalCount), Mode=OneWay}" />
<Button
Content="Cancel"
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
<KeyboardAccelerator Key="Escape" />
</Button.KeyboardAccelerators>
</Button>
</StackPanel>
</Grid>
<!-- Active strip: exactly the repos cloning/pulling right now, bounded by
the parallelism setting, so the table never moves under the user. -->
<ItemsControl
x:Name="ActiveStrip"
Grid.Row="1"
Margin="0,8,0,0"
MaxHeight="64"
AutomationProperties.Name="Repositories syncing now"
ItemsSource="{x:Bind ViewModel.ActiveRepos, Mode=OneTime}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsRunning), Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VariableSizedWrapGrid Orientation="Horizontal" ItemWidth="208" ItemHeight="30" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:RepoItemViewModel">
<Border
Margin="0,0,8,4"
Padding="10,4"
Background="{ThemeResource ControlFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="13">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Style="{ThemeResource CaptionTextBlockStyle}"
TextTrimming="CharacterEllipsis"
Text="{x:Bind Name, Mode=OneTime}" />
<ProgressBar
Grid.Column="1"
Width="56"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind local:StatusFormat.ProgressAutomationName(Name), Mode=OneTime}"
Maximum="1"
Value="{x:Bind ProgressValue, Mode=OneWay}"
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Run results: opens when a run ends, user-dismissable (two-way IsOpen). -->
<InfoBar
Grid.Row="2"
Margin="0,8,0,0"
IsOpen="{x:Bind ViewModel.ResultOpen, Mode=TwoWay}"
Severity="{x:Bind local:StatusFormat.SeverityFor(ViewModel.ResultKind), Mode=OneWay}"
Title="{x:Bind local:StatusFormat.TitleFor(ViewModel.ResultKind), Mode=OneWay}"
Message="{x:Bind ViewModel.ResultMessage, Mode=OneWay}">
<StackPanel
Margin="0,0,0,12"
Orientation="Horizontal"
Spacing="8">
<Button
Content="Retry failed"
Command="{x:Bind ViewModel.RetryFailedCommand, Mode=OneTime}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasFailedRepos), Mode=OneWay}" />
<Button Content="Open folder" Click="OpenFolderButton_Click" />
</StackPanel>
</InfoBar>
<!-- Table header: fixed column widths mirrored by the row template below.
Sort arrows are visual-only; each button carries a composed
automation name ("Name, sorted ascending"). -->
<Grid Grid.Row="3" Margin="0,8,0,0" Padding="12,0,12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Column="0"
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
ToolTipService.ToolTip="Select all"
AutomationProperties.Name="Select all repositories"
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
<Button
Grid.Column="1"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
AutomationProperties.Name="{x:Bind SortAutomationName('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Name">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Name" />
<FontIcon
FontSize="10"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Glyph="{x:Bind SortGlyph('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Visibility="{x:Bind SortGlyphVisibility('Name', ViewModel.SortColumn), Mode=OneWay}" />
</StackPanel>
</Button>
<Button
Grid.Column="2"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
AutomationProperties.Name="{x:Bind SortAutomationName('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Status">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Status" />
<FontIcon
FontSize="10"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Glyph="{x:Bind SortGlyph('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Visibility="{x:Bind SortGlyphVisibility('Status', ViewModel.SortColumn), Mode=OneWay}" />
</StackPanel>
</Button>
<Button
Grid.Column="3"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
AutomationProperties.Name="{x:Bind SortAutomationName('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Branch">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Branch" />
<FontIcon
FontSize="10"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Glyph="{x:Bind SortGlyph('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Visibility="{x:Bind SortGlyphVisibility('Branch', ViewModel.SortColumn), Mode=OneWay}" />
</StackPanel>
</Button>
<Button
Grid.Column="4"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Background="Transparent"
BorderThickness="0"
Padding="0,4,0,4"
AutomationProperties.Name="{x:Bind SortAutomationName('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
CommandParameter="Archived">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Archived" />
<FontIcon
FontSize="10"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Glyph="{x:Bind SortGlyph('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
Visibility="{x:Bind SortGlyphVisibility('Archived', ViewModel.SortColumn), Mode=OneWay}" />
</StackPanel>
</Button>
</Grid>
<!-- Per-repo table body. The ListView carries the view model as DataContext so
the row template's Resolve link can reach WorkspaceViewModel.ResolvePathsCommand
via ElementName (x:Bind inside the template is scoped to the row item). -->
<ListView
x:Name="RepoListView"
Grid.Row="4"
DataContext="{x:Bind ViewModel, Mode=OneTime}"
ItemsSource="{x:Bind ViewModel.FilteredRepos, Mode=OneTime}"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="12,0,12,0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:RepoItemViewModel">
<Grid Padding="0,6,0,6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Row="0"
Grid.Column="0"
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
<TextBlock
Grid.Row="0"
Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
Text="{x:Bind Name, Mode=OneTime}" />
<StackPanel
Grid.Row="0"
Grid.Column="2"
Margin="0,0,12,0"
VerticalAlignment="Center"
Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon
FontSize="12"
VerticalAlignment="Center"
Glyph="{x:Bind local:StatusFormat.GlyphFor(Status), Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
<TextBlock
Text="{x:Bind StatusText, Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
</StackPanel>
<ProgressBar
Maximum="1"
Value="{x:Bind ProgressValue, Mode=OneWay}"
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ShowProgress), Mode=OneWay}" />
</StackPanel>
<TextBlock
Grid.Row="0"
Grid.Column="3"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind BranchText, Mode=OneTime}" />
<TextBlock
Grid.Row="0"
Grid.Column="4"
VerticalAlignment="Center"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Archived"
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsArchived), Mode=OneTime}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="5"
TextWrapping="Wrap"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Text="{x:Bind Error, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
<HyperlinkButton
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="5"
Padding="0"
HorizontalAlignment="Left"
AutomationProperties.Name="{x:Bind local:StatusFormat.ResolveAutomationName(Name), Mode=OneTime}"
Content="Resolve…"
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoListView}"
CommandParameter="{x:Bind}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasPathIssue), Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Shown when the current filter hides everything the table has. -->
<TextBlock
x:Name="EmptyFilterText"
Grid.Row="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="No repositories match this filter."
Visibility="Collapsed" />
</Grid>
</Grid>
<!-- Load-phase status and the page's assistive-technology live region. Kept in
the tree (with its name) so announcements keep working in both states. -->
<TextBlock
x:Name="StatusTextBlock"
Grid.Row="1"
AutomationProperties.LiveSetting="Polite"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.StatusText, Mode=OneWay}"
TextWrapping="Wrap" />
</Grid>
</UserControl>
+99 -29
View File
@@ -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
{
/// <summary>
/// One sync workspace: inputs, the two-phase Load/Sync actions, overall progress,
/// and the per-repo table. Hosted by <see cref="MainWindow"/>'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 <see cref="MainWindow"/>'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();
}
/// <summary>
@@ -71,28 +77,91 @@ namespace gclo
? "Create org subfolder"
: $"Create {organization.Trim()} subfolder";
/// <summary>
/// Header text for a sortable table column, with an arrow on the active sort column.
/// </summary>
public string SortHeader(string column, string? sortColumn, bool sortDescending)
=> column == sortColumn ? column + (sortDescending ? " ▼" : " ▲") : column;
/// <summary>True when this workspace is backed by a saved account (vs Quick Sync).</summary>
public bool IsAccountWorkspace => ViewModel.AccountId is not null;
/// <summary>
/// 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
/// <see cref="WorkspaceViewModel.CanEditInputs"/>; account workspaces are always
/// read-only here (their settings are edited in the account wizard).
/// </summary>
public Visibility AnyFailedVisibility(int completedCount, bool isRunning)
public bool ConnectFieldsEnabled(bool canEditInputs)
=> canEditInputs && ViewModel.AccountId is null;
/// <summary>
/// Sort-direction arrow (visual only; the header button's automation name
/// carries the state) for the active sort column: chevron up = ascending.
/// </summary>
public string SortGlyph(string column, string? sortColumn, bool sortDescending)
=> column == sortColumn ? (sortDescending ? "" : "") : "";
/// <summary>Shows the sort arrow only on the column the table is sorted by.</summary>
public Visibility SortGlyphVisibility(string column, string? sortColumn)
=> column == sortColumn ? Visibility.Visible : Visibility.Collapsed;
/// <summary>
/// Composed automation name for a sortable column header, e.g.
/// "Name, sorted ascending" or "Branch, not sorted".
/// </summary>
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();
}
}
/// <summary>
/// Refreshes the count-derived texts: the chip's repository count, the
/// toolbar's "N of M selected" summary, and the empty-filter placeholder.
/// </summary>
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;