Phase 3: workspace redesign #27
No files matched your search
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 <-> 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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="" />
|
||||
<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="" />
|
||||
<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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user