diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
similarity index 78%
rename from gclo.Engine.Tests/MainViewModelTests.cs
rename to gclo.Engine.Tests/WorkspaceViewModelTests.cs
index 22dbee8..95f858b 100644
--- a/gclo.Engine.Tests/MainViewModelTests.cs
+++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
@@ -5,11 +5,11 @@ using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
///
-/// Headless tests for using a synchronous progress factory
+/// Headless tests for using a synchronous progress factory
/// (the production default, , posts asynchronously and would
/// make assertions racy) and a near-zero org-lookup debounce.
///
-public sealed class MainViewModelTests
+public sealed class WorkspaceViewModelTests
{
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
@@ -21,18 +21,23 @@ public sealed class MainViewModelTests
public void Report(RepoProgress value) => handler(value);
}
- private MainViewModel CreateViewModel(TimeSpan? debounce = null)
+ private WorkspaceViewModel CreateViewModel(
+ TimeSpan? debounce = null,
+ Account? account = null,
+ ITokenVault? vault = null,
+ AccountsStore? store = null)
=> new(_lister, _git, _orgs,
handler => new SyncProgress(handler),
debounce ?? TimeSpan.FromMilliseconds(1),
- new NullActivityLog());
+ new NullActivityLog(),
+ account, vault, store);
///
/// A view model with valid inputs and the given repositories already loaded into the
/// table. Waits out the token-triggered org lookup first so its status message cannot
/// land on top of a later assertion.
///
- private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos)
+ private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos)
{
_lister.Repositories = repos;
var vm = CreateViewModel();
@@ -557,8 +562,8 @@ public sealed class MainViewModelTests
Assert.Equal(@"C:\src\acme", vm.EffectiveTargetRoot);
Assert.Equal(@"C:\src\acme\my-repo", vm.TargetPreview);
- Assert.Contains(nameof(MainViewModel.EffectiveTargetRoot), raised);
- Assert.Contains(nameof(MainViewModel.TargetPreview), raised);
+ Assert.Contains(nameof(WorkspaceViewModel.EffectiveTargetRoot), raised);
+ Assert.Contains(nameof(WorkspaceViewModel.TargetPreview), raised);
}
[Fact]
@@ -668,6 +673,187 @@ public sealed class MainViewModelTests
Assert.Equal(1, _orgs.Calls);
}
+ // ---------------------------------------------------------------- account workspaces
+
+ /// An account profile whose repositories land under .
+ private static Account MakeAccount(string targetRoot) => new()
+ {
+ Id = Guid.NewGuid(),
+ Name = "Work",
+ Organization = "acme",
+ TargetRoot = targetRoot,
+ MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads
+ };
+
+ [Fact]
+ public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken()
+ {
+ var account = new Account
+ {
+ Id = Guid.NewGuid(),
+ Name = "Work",
+ Organization = "acme",
+ TargetRoot = @"C:\repos\work",
+ CreateOrgSubfolder = true,
+ MaxConcurrency = 4,
+ };
+ var vault = new InMemoryVault();
+ vault.Store(account.Id, "vault-token-1234567890");
+ string? lookedUpToken = null;
+ _orgs.Handler = (token, _) =>
+ {
+ lookedUpToken = token;
+ return Task.FromResult>(["acme", "other"]);
+ };
+
+ var vm = CreateViewModel(account: account, vault: vault);
+
+ Assert.Equal("acme", vm.Organization);
+ Assert.Equal(@"C:\repos\work", vm.TargetFolder);
+ Assert.True(vm.CreateOrgSubfolder);
+ Assert.Equal(4, vm.MaxConcurrency);
+ Assert.Equal("vault-token-1234567890", vm.Token);
+ Assert.Equal(account.Id, vm.AccountId);
+ Assert.Equal("Work", vm.DisplayName);
+
+ await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire");
+ Assert.Equal("vault-token-1234567890", lookedUpToken);
+ }
+
+ [Fact]
+ public void QuickSyncConstruction_KeepsDefaults_AndQuickSyncIdentity()
+ {
+ var vm = CreateViewModel();
+
+ Assert.Null(vm.AccountId);
+ Assert.Equal("Quick Sync", vm.DisplayName);
+ Assert.Equal("", vm.Organization);
+ Assert.Equal("", vm.Token);
+ Assert.Equal("", vm.TargetFolder);
+ Assert.False(vm.CreateOrgSubfolder);
+ Assert.Equal(AppSettings.DefaultConcurrency, vm.MaxConcurrency);
+ Assert.False(vm.HasFailedRepos);
+ }
+
+ [Fact]
+ public async Task HasFailedRepos_TurnsOnWithAFailure_AndOffWhenRetrySucceeds()
+ {
+ _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"));
+ Assert.False(vm.HasFailedRepos);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+ Assert.True(vm.HasFailedRepos);
+
+ _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone
+ await vm.RetryFailedCommand.ExecuteAsync(null);
+ Assert.False(vm.HasFailedRepos);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task Sync_AccountWorkspace_RecordsTheResultOnTheStore()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ var vault = new InMemoryVault();
+ var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog());
+ var account = MakeAccount(Path.Combine(root, "repos"));
+ store.Save(account, "token-1234567890"); // the vault feeds the VM its token
+ _lister.Repositories = [Repo("alpha"), Repo("bravo")];
+
+ var vm = CreateViewModel(account: account, vault: vault, store: store);
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ await vm.LoadReposCommand.ExecuteAsync(null);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Account recorded = Assert.Single(store.GetAll());
+ Assert.NotNull(recorded.LastSyncUtc);
+ Assert.NotNull(recorded.LastSyncSummary);
+ Assert.StartsWith("Finished", recorded.LastSyncSummary);
+ Assert.Contains("2 cloned", recorded.LastSyncSummary);
+ Assert.Equal(vm.StatusText, recorded.LastSyncSummary);
+ }
+ finally
+ {
+ TryDeleteDirectory(root);
+ }
+ }
+
+ [Fact]
+ public async Task Sync_QuickSync_DoesNotRecordOnTheStore()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ var vault = new InMemoryVault();
+ var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog());
+ store.Save(MakeAccount(Path.Combine(root, "repos")), "token-1234567890");
+ _lister.Repositories = [Repo("alpha")];
+
+ // Quick Sync: the shared store exists, but no account backs this workspace.
+ var vm = CreateViewModel(vault: vault, store: store);
+ vm.Organization = "acme";
+ vm.Token = "token-1234567890";
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ vm.TargetFolder = Path.Combine(root, "quick");
+ vm.MaxConcurrencyValue = 1;
+ await vm.LoadReposCommand.ExecuteAsync(null);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.Contains("1 cloned", vm.StatusText);
+ Account untouched = Assert.Single(store.GetAll());
+ Assert.Null(untouched.LastSyncUtc);
+ Assert.Null(untouched.LastSyncSummary);
+ }
+ finally
+ {
+ TryDeleteDirectory(root);
+ }
+ }
+
+ [Fact]
+ public async Task Sync_RecordSyncResultFailure_StillCompletesTheRun()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ string storeDir = Path.Combine(root, "store");
+ try
+ {
+ var vault = new InMemoryVault();
+ var store = new AccountsStore(vault, storeDir, new NullActivityLog());
+ var account = MakeAccount(Path.Combine(root, "repos"));
+ store.Save(account, "token-1234567890");
+ _lister.Repositories = [Repo("alpha")];
+
+ var vm = CreateViewModel(account: account, vault: vault, store: store);
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ await vm.LoadReposCommand.ExecuteAsync(null);
+
+ // Break persistence: a file now squats on the store's directory path, so
+ // RecordSyncResult's write throws. The finished sync must shrug that off.
+ Directory.Delete(storeDir, recursive: true);
+ File.WriteAllText(storeDir, "not a directory");
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.Equal("Finished: 1 cloned, 0 updated, 0 failed, 0 canceled of 1.", vm.StatusText);
+ Assert.True(vm.HasCompletedRun);
+ Assert.False(vm.IsRunning);
+ Assert.False(vm.HasFailedRepos);
+ }
+ finally
+ {
+ TryDeleteDirectory(root);
+ }
+ }
+
// ---------------------------------------------------------------- misc bindings
[Theory]
diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs
similarity index 88%
rename from gclo.ViewModels/MainViewModel.cs
rename to gclo.ViewModels/WorkspaceViewModel.cs
index 44797e7..43ad048 100644
--- a/gclo.ViewModels/MainViewModel.cs
+++ b/gclo.ViewModels/WorkspaceViewModel.cs
@@ -7,11 +7,14 @@ using gclo.Engine;
namespace gclo.ViewModels;
///
-/// Drives the org-sync UI in two phases: fills the repository
-/// table, then clones or updates the selected subset with live
-/// per-repo progress. re-runs just the failures.
+/// Drives one workspace of the org-sync UI in two phases:
+/// fills the repository table, then clones or updates the
+/// selected subset with live per-repo progress. re-runs
+/// just the failures. A workspace is either backed by a saved
+/// (seeded from its profile, with each finished sync recorded back to the
+/// ) or the ad-hoc Quick Sync mode when constructed without one.
///
-public sealed partial class MainViewModel : ObservableObject, IDisposable
+public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
private readonly IRepositoryLister _lister;
private readonly IGitClient _git;
@@ -19,6 +22,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly Func, IProgress> _progressFactory;
private readonly TimeSpan _orgLookupDebounce;
private readonly IActivityLog _log;
+ private readonly Account? _account;
+ private readonly AccountsStore? _accountsStore;
private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _orgLoadCts;
@@ -32,14 +37,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
/// Production dependencies by default; pass fakes for testing. The default progress
/// factory is , which marshals via the SynchronizationContext
/// captured at construction (the UI thread in the app); tests inject a synchronous one.
+ /// A non-null seeds the workspace from that profile — its
+ /// organization, target folder, subfolder preference, and concurrency — and pulls the
+ /// token from (the Token setter's org lookup fires
+ /// naturally); finished syncs are then recorded on .
///
- public MainViewModel(
+ public WorkspaceViewModel(
IRepositoryLister? lister = null,
IGitClient? git = null,
IOrganizationLister? orgLister = null,
Func, IProgress>? progressFactory = null,
TimeSpan? orgLookupDebounce = null,
- IActivityLog? log = null)
+ IActivityLog? log = null,
+ Account? account = null,
+ ITokenVault? tokenVault = null,
+ AccountsStore? accountsStore = null)
{
_lister = lister ?? new GitHubRepositoryLister();
_git = git ?? new LibGit2GitClient();
@@ -47,14 +59,32 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
_progressFactory = progressFactory ?? (handler => new Progress(handler));
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
_log = log ?? new FileActivityLog();
+ _account = account;
+ _accountsStore = accountsStore;
Organization = "";
Token = "";
TargetFolder = "";
MaxConcurrency = AppSettings.DefaultConcurrency;
StatusText = "";
AllSelected = true;
+
+ if (account is not null)
+ {
+ Organization = account.Organization;
+ TargetFolder = account.TargetRoot;
+ CreateOrgSubfolder = account.CreateOrgSubfolder;
+ MaxConcurrency = account.MaxConcurrency;
+ // The Token setter's existing org lookup fires naturally with the vault token.
+ Token = tokenVault?.TryRetrieve(account.Id) ?? "";
+ }
}
+ /// Id of the account this workspace was created for, or null for Quick Sync.
+ public Guid? AccountId => _account?.Id;
+
+ /// Name shown for this workspace in navigation: the account's name, or "Quick Sync".
+ public string DisplayName => _account?.Name ?? "Quick Sync";
+
public ObservableCollection Repos { get; } = new();
/// Organizations discovered from the current token; feeds the org dropdown.
@@ -111,6 +141,10 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool HasCompletedRun { get; set; }
+ /// True while any row is in the Failed state; drives the navigation badge.
+ [ObservableProperty]
+ public partial bool HasFailedRepos { get; set; }
+
/// Column the table is currently sorted by, or null for load order.
[ObservableProperty]
public partial string? SortColumn { get; set; }
@@ -372,6 +406,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
NotifyTargetPathChanged();
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
+ RecomputeHasFailedRepos();
}
}
@@ -419,6 +454,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
+ $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}.";
StatusText = summaryText;
_log.Info(summaryText);
+
+ if (_account is not null && _accountsStore is not null)
+ {
+ try
+ {
+ _accountsStore.RecordSyncResult(_account.Id, DateTimeOffset.UtcNow, summaryText);
+ }
+ catch (Exception ex)
+ {
+ // Bookkeeping only: failing to stamp the account must not turn a
+ // completed sync into an error.
+ _log.Error(
+ $"Failed to record the sync result for account '{_account.Name}': {ex.Message}", ex);
+ }
+ }
}
catch (OperationCanceledException)
{
@@ -609,5 +659,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
}
}
CompletedCount = completed;
+ RecomputeHasFailedRepos();
}
+
+ private void RecomputeHasFailedRepos()
+ => HasFailedRepos = Repos.Any(r => r.Status == SyncStatus.Failed);
}
diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml
index 2e630ba..4970ea7 100644
--- a/gclo/MainWindow.xaml
+++ b/gclo/MainWindow.xaml
@@ -4,7 +4,6 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:gclo"
- xmlns:vm="using:gclo.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
@@ -41,315 +40,32 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index 00083fc..00b23f3 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -1,17 +1,23 @@
using System;
-using System.IO;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
using System.Threading.Tasks;
using gclo.Engine;
using gclo.Services;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
namespace gclo
{
///
- /// Main (and only) window: sync inputs, the two-phase Load/Sync actions, overall
- /// progress, and the per-repo table.
+ /// Shell window: the menu bar plus a navigation pane with the pinned Quick Sync
+ /// workspace and one workspace per saved account. Each workspace (a
+ /// and its ) is created
+ /// on first visit and cached, so switching away never interrupts a running sync;
+ /// pane badges surface running/failed state for the workspaces not on screen.
///
public sealed partial class MainWindow : Window
{
@@ -20,38 +26,61 @@ namespace gclo
private readonly AppSettings _settings;
private readonly UpdateService _updateService = new();
- // Shared with the view model so the log viewer shows the same file the VM writes to.
+ // One log, vault, and store shared by every workspace (and the log viewer),
+ // so all workspaces write to the same file and read the same accounts.
private readonly IActivityLog _log;
+ private readonly ITokenVault _tokenVault;
+ private readonly AccountsStore _accountsStore;
- public MainViewModel ViewModel { get; }
+ /// Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync).
+ private readonly Dictionary _workspaces = new();
+
+ /// Badge subscriptions per workspace, unhooked before the view models are disposed.
+ private readonly Dictionary _badgeHandlers = new();
+
+ /// Navigation items by workspace id, for badge and tooltip refreshes.
+ private readonly Dictionary _navItems = new();
+
+ /// The workspace the pane currently rests on; where 'Add account' reverts to.
+ private Guid _currentWorkspaceId = Guid.Empty;
+
+ /// Suppresses re-entrant SelectionChanged while the code puts a selection back.
+ private bool _revertingSelection;
public MainWindow()
{
_log = new FileActivityLog();
- ViewModel = new MainViewModel(log: _log);
- InitializeComponent();
+ _tokenVault = new CredentialManagerVault();
+ _accountsStore = new AccountsStore(_tokenVault, log: _log);
- 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(MainViewModel.StatusText))
- {
- // Enqueued so the binding has pushed the new text before the event.
- DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
- }
- };
+ InitializeComponent();
Title = "gclo — Git Clone Large Organizations";
- // The view model stays UI-free: when ResolvePathsCommand needs the user's
- // path-recovery choices, it calls back through here and the window answers
- // with PathRecoveryDialog.
- ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync;
+ // Quick Sync is declared in XAML; its Guid tag and the account items are
+ // runtime data, so they are filled in here.
+ QuickSyncNavItem.Tag = Guid.Empty;
+ _navItems[Guid.Empty] = QuickSyncNavItem;
+ foreach (Account account in _accountsStore.GetAll())
+ {
+ var item = new NavigationViewItem
+ {
+ Content = account.Name,
+ Tag = account.Id,
+ Icon = new FontIcon { Glyph = "" },
+ };
+ ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
+ _navItems[account.Id] = item;
+ WorkspaceNav.MenuItems.Add(item);
+ }
_settings = AppSettings.Load();
+
+ // Create and show Quick Sync before selecting it, so NavigationView.Content
+ // is never empty regardless of when SelectionChanged fires.
+ ShowWorkspace(Guid.Empty);
ApplySettings();
+ WorkspaceNav.SelectedItem = QuickSyncNavItem;
// AppWindow.Resize takes physical pixels; scale by the monitor DPI so the
// window is the same visual size at 150%/200% display scaling.
@@ -59,16 +88,7 @@ namespace gclo
double scale = GetDpiForWindow(hwnd) / 96.0;
AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale)));
- // Best effort: stop in-flight git work when the window closes so clones are
- // not killed mid-checkout (a killed clone is repaired on the next run).
- Closed += (_, _) =>
- {
- if (ViewModel.SyncCancelCommand.CanExecute(null))
- {
- ViewModel.SyncCancelCommand.Execute(null);
- }
- ViewModel.Dispose();
- };
+ Closed += (_, _) => DisposeWorkspaces();
}
private void ApplySettings()
@@ -77,46 +97,204 @@ namespace gclo
{
root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme);
}
- if (string.IsNullOrWhiteSpace(ViewModel.TargetFolder)
- && !string.IsNullOrWhiteSpace(_settings.DefaultTargetFolder))
+
+ // Settings defaults only seed the ad-hoc Quick Sync workspace; account
+ // workspaces carry their own configuration.
+ if (_workspaces.TryGetValue(Guid.Empty, out var quickSync))
{
- ViewModel.TargetFolder = _settings.DefaultTargetFolder;
+ WorkspaceViewModel viewModel = quickSync.ViewModel;
+ if (string.IsNullOrWhiteSpace(viewModel.TargetFolder)
+ && !string.IsNullOrWhiteSpace(_settings.DefaultTargetFolder))
+ {
+ viewModel.TargetFolder = _settings.DefaultTargetFolder;
+ }
+ viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
}
- ViewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
}
- ///
- /// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
- ///
- public string OrgSubfolderLabel(string organization)
- => string.IsNullOrWhiteSpace(organization)
- ? "Create org subfolder"
- : $"Create {organization.Trim()} subfolder";
+ // ---------------------------------------------------------------- workspaces
- ///
- /// Header text for a sortable table column, with an arrow on the active sort column.
- ///
- public string SortHeader(string column, string? sortColumn, bool sortDescending)
- => column == sortColumn ? column + (sortDescending ? " ▼" : " ▲") : column;
-
- ///
- /// 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.
- ///
- public Visibility AnyFailedVisibility(int completedCount, bool isRunning)
+ private async void WorkspaceNav_SelectionChanged(
+ NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
- foreach (RepoItemViewModel repo in ViewModel.Repos)
+ if (_revertingSelection || args.SelectedItem is not NavigationViewItem item)
{
- if (repo.Status == SyncStatus.Failed)
+ return;
+ }
+
+ if (item.Tag is "add")
+ {
+ // The footer entry is a command, not a destination: put the selection
+ // back on the current workspace, then show the stub dialog.
+ _revertingSelection = true;
+ try
{
- return Visibility.Visible;
+ WorkspaceNav.SelectedItem =
+ _navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current)
+ ? current
+ : QuickSyncNavItem;
+ }
+ finally
+ {
+ _revertingSelection = false;
+ }
+ await ShowAddAccountStubAsync();
+ return;
+ }
+
+ if (item.Tag is Guid id)
+ {
+ ShowWorkspace(id);
+ if (_workspaces.ContainsKey(id))
+ {
+ _currentWorkspaceId = id;
}
}
- return Visibility.Collapsed;
}
+ ///
+ /// Puts the workspace for on screen, creating and caching
+ /// it on first visit. Cached view models keep running syncs alive across switches.
+ ///
+ private void ShowWorkspace(Guid id)
+ {
+ if (!_workspaces.TryGetValue(id, out var workspace))
+ {
+ WorkspaceViewModel? viewModel = CreateWorkspaceViewModel(id);
+ if (viewModel is null)
+ {
+ return; // stale item: the account no longer exists in the store
+ }
+
+ var page = new WorkspacePage(
+ viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this));
+ workspace = (viewModel, page);
+ _workspaces[id] = workspace;
+
+ PropertyChangedEventHandler handler = (_, e) =>
+ {
+ if (e.PropertyName is nameof(WorkspaceViewModel.IsRunning)
+ or nameof(WorkspaceViewModel.HasFailedRepos))
+ {
+ OnWorkspaceStateChanged(id);
+ }
+ };
+ viewModel.PropertyChanged += handler;
+ _badgeHandlers[id] = handler;
+ }
+
+ WorkspaceNav.Content = workspace.Page;
+ }
+
+ private WorkspaceViewModel? CreateWorkspaceViewModel(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ // Quick Sync: no account behind it — exactly the pre-accounts behavior.
+ return new WorkspaceViewModel(log: _log);
+ }
+
+ Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
+ if (account is null)
+ {
+ _log.Error($"No account with id {id:N}; cannot open its workspace.");
+ return null;
+ }
+ return new WorkspaceViewModel(
+ account: account,
+ tokenVault: _tokenVault,
+ accountsStore: _accountsStore,
+ log: _log);
+ }
+
+ ///
+ /// Refreshes a workspace's pane badge (attention dot while running, critical dot
+ /// when it has failures) and, after a run, its tooltip from the stored summary.
+ ///
+ private void OnWorkspaceStateChanged(Guid id)
+ {
+ if (!_navItems.TryGetValue(id, out NavigationViewItem? item)
+ || !_workspaces.TryGetValue(id, out var workspace))
+ {
+ return;
+ }
+
+ WorkspaceViewModel viewModel = workspace.ViewModel;
+ item.InfoBadge = viewModel.IsRunning
+ ? CreateDotBadge("AttentionDotInfoBadgeStyle", "SystemFillColorAttentionBrush")
+ : viewModel.HasFailedRepos
+ ? CreateDotBadge("CriticalDotInfoBadgeStyle", "SystemFillColorCriticalBrush")
+ : null;
+
+ // A finished run has stamped its outcome onto the account by now.
+ if (!viewModel.IsRunning && id != Guid.Empty)
+ {
+ Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
+ if (account is not null)
+ {
+ ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
+ }
+ }
+ }
+
+ ///
+ /// A value-less (dot) InfoBadge, styled by the WinUI dot-badge resource when
+ /// present, otherwise tinted directly from the matching theme fill brush.
+ ///
+ private static InfoBadge CreateDotBadge(string styleKey, string fallbackBrushKey)
+ {
+ var badge = new InfoBadge();
+ if (Application.Current.Resources.TryGetValue(styleKey, out object? styleValue)
+ && styleValue is Style style)
+ {
+ badge.Style = style;
+ }
+ else if (Application.Current.Resources.TryGetValue(fallbackBrushKey, out object? brushValue)
+ && brushValue is Brush brush)
+ {
+ badge.Background = brush;
+ }
+ return badge;
+ }
+
+ private async Task ShowAddAccountStubAsync()
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "Add account",
+ Content = "The account setup wizard arrives in the next update. "
+ + "Accounts created by future versions appear here automatically.",
+ CloseButtonText = "OK",
+ XamlRoot = Content.XamlRoot,
+ };
+ await dialog.ShowAsync();
+ }
+
+ ///
+ /// Best effort on close: cancel in-flight git work in every cached workspace so
+ /// clones are not killed mid-checkout (a killed clone is repaired on the next
+ /// run), then dispose the view models and drop the badge subscriptions.
+ ///
+ private void DisposeWorkspaces()
+ {
+ foreach ((Guid id, var workspace) in _workspaces)
+ {
+ if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler))
+ {
+ workspace.ViewModel.PropertyChanged -= handler;
+ }
+ if (workspace.ViewModel.SyncCancelCommand.CanExecute(null))
+ {
+ workspace.ViewModel.SyncCancelCommand.Execute(null);
+ }
+ workspace.ViewModel.Dispose();
+ }
+ _badgeHandlers.Clear();
+ _workspaces.Clear();
+ }
+
+ // ---------------------------------------------------------------- menu bar
+
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot };
@@ -129,35 +307,6 @@ namespace gclo
private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close();
- private void RaiseStatusLiveRegionChanged()
- {
- var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock)
- ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock);
- peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
- }
-
- ///
- /// Raises a UIA notification so screen readers hear per-repo failures. Routed
- /// through the ListView's peer: panels like Grid have no automation peer.
- /// ImportantMostRecent coalesces bursts when many repositories fail at once.
- ///
- private void AnnounceToAssistiveTechnology(string message)
- {
- if (!DispatcherQueue.HasThreadAccess)
- {
- DispatcherQueue.TryEnqueue(() => AnnounceToAssistiveTechnology(message));
- return;
- }
-
- var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoListView)
- ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoListView);
- peer?.RaiseNotificationEvent(
- Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted,
- Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent,
- message,
- "gclo-repo-status");
- }
-
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
@@ -225,23 +374,6 @@ namespace gclo
}
}
- ///
- /// Shows for a repository whose checkout was
- /// blocked by Windows-invalid paths. Returns the user's recovery choice, or null
- /// when the dialog was dismissed (or the row carries no path details).
- ///
- private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item)
- {
- if (item.InvalidPaths is not { Count: > 0 } paths)
- {
- return null;
- }
-
- var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = Content.XamlRoot };
- await dialog.ShowAsync();
- return dialog.Result;
- }
-
private async Task ShowUpdateMessageAsync(string title, string message)
{
var dialog = new ContentDialog
@@ -256,36 +388,5 @@ namespace gclo
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hwnd);
-
- // PasswordBox does not support reliable two-way x:Bind on Password;
- // mirror it into the view model by hand.
- private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
- {
- ViewModel.Token = ((PasswordBox)sender).Password;
- }
-
- private async void BrowseButton_Click(object sender, RoutedEventArgs e)
- {
- var picker = new Windows.Storage.Pickers.FolderPicker();
- picker.FileTypeFilter.Add("*"); // required in packaged apps
-
- var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
- WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
-
- var folder = await picker.PickSingleFolderAsync();
- if (folder is not null)
- {
- ViewModel.TargetFolder = folder.Path;
- }
- }
-
- private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
- {
- string root = ViewModel.EffectiveTargetRoot;
- if (Directory.Exists(root))
- {
- await Windows.System.Launcher.LaunchFolderPathAsync(root);
- }
- }
}
}
diff --git a/gclo/StatusFormat.cs b/gclo/StatusFormat.cs
index 8161aa1..0c6d904 100644
--- a/gclo/StatusFormat.cs
+++ b/gclo/StatusFormat.cs
@@ -5,7 +5,7 @@ using Microsoft.UI.Xaml.Media;
namespace gclo;
-/// Static helpers for x:Bind function bindings in MainWindow.xaml.
+/// Static helpers for x:Bind function bindings in WorkspacePage.xaml.
public static class StatusFormat
{
///
diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml
new file mode 100644
index 0000000..78a6b95
--- /dev/null
+++ b/gclo/WorkspacePage.xaml
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo/WorkspacePage.xaml.cs b/gclo/WorkspacePage.xaml.cs
new file mode 100644
index 0000000..142b879
--- /dev/null
+++ b/gclo/WorkspacePage.xaml.cs
@@ -0,0 +1,174 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using gclo.Engine;
+using gclo.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace gclo
+{
+ ///
+ /// One sync workspace: inputs, the two-phase Load/Sync actions, overall progress,
+ /// and the per-repo table. Hosted by 's navigation shell —
+ /// one 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.
+ ///
+ public sealed partial class WorkspacePage : UserControl
+ {
+ // A UserControl has no HWND of its own; the host window supplies one for pickers.
+ private readonly Func _windowHandleProvider;
+
+ /// The workspace state this page renders; target of every x:Bind.
+ public WorkspaceViewModel ViewModel { get; }
+
+ ///
+ /// Wires the page to its (caller-owned) view model. The
+ /// returns the host window's HWND,
+ /// needed to initialize the folder picker.
+ ///
+ public WorkspacePage(WorkspaceViewModel viewModel, Func windowHandleProvider)
+ {
+ ArgumentNullException.ThrowIfNull(viewModel);
+ ArgumentNullException.ThrowIfNull(windowHandleProvider);
+ ViewModel = viewModel;
+ _windowHandleProvider = windowHandleProvider;
+ 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);
+ }
+ };
+
+ // The view model stays UI-free: when ResolvePathsCommand needs the user's
+ // path-recovery choices, it calls back through here and the page answers
+ // with PathRecoveryDialog.
+ ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync;
+
+ // An account-seeded view model already carries its vault token; mirror it
+ // into the box so the UI shows what is in effect. The resulting
+ // PasswordChanged echoes the same value back, which the Token setter
+ // ignores as a no-op.
+ if (ViewModel.Token.Length > 0)
+ {
+ TokenBox.Password = ViewModel.Token;
+ }
+ }
+
+ ///
+ /// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
+ ///
+ public string OrgSubfolderLabel(string organization)
+ => string.IsNullOrWhiteSpace(organization)
+ ? "Create org subfolder"
+ : $"Create {organization.Trim()} subfolder";
+
+ ///
+ /// Header text for a sortable table column, with an arrow on the active sort column.
+ ///
+ public string SortHeader(string column, string? sortColumn, bool sortDescending)
+ => column == sortColumn ? column + (sortDescending ? " ▼" : " ▲") : column;
+
+ ///
+ /// 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.
+ ///
+ public Visibility AnyFailedVisibility(int completedCount, bool isRunning)
+ {
+ foreach (RepoItemViewModel repo in ViewModel.Repos)
+ {
+ if (repo.Status == SyncStatus.Failed)
+ {
+ return Visibility.Visible;
+ }
+ }
+ return Visibility.Collapsed;
+ }
+
+ private void RaiseStatusLiveRegionChanged()
+ {
+ var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock)
+ ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock);
+ peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
+ }
+
+ ///
+ /// Raises a UIA notification so screen readers hear per-repo failures. Routed
+ /// through the ListView's peer: panels like Grid have no automation peer.
+ /// ImportantMostRecent coalesces bursts when many repositories fail at once.
+ ///
+ private void AnnounceToAssistiveTechnology(string message)
+ {
+ if (!DispatcherQueue.HasThreadAccess)
+ {
+ DispatcherQueue.TryEnqueue(() => AnnounceToAssistiveTechnology(message));
+ return;
+ }
+
+ var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoListView)
+ ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoListView);
+ peer?.RaiseNotificationEvent(
+ Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted,
+ Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent,
+ message,
+ "gclo-repo-status");
+ }
+
+ ///
+ /// Shows for a repository whose checkout was
+ /// blocked by Windows-invalid paths. Returns the user's recovery choice, or null
+ /// when the dialog was dismissed (or the row carries no path details).
+ ///
+ private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item)
+ {
+ if (item.InvalidPaths is not { Count: > 0 } paths)
+ {
+ return null;
+ }
+
+ var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = XamlRoot };
+ await dialog.ShowAsync();
+ return dialog.Result;
+ }
+
+ // PasswordBox does not support reliable two-way x:Bind on Password;
+ // mirror it into the view model by hand.
+ private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
+ {
+ ViewModel.Token = ((PasswordBox)sender).Password;
+ }
+
+ private async void BrowseButton_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new Windows.Storage.Pickers.FolderPicker();
+ picker.FileTypeFilter.Add("*"); // required in packaged apps
+
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider());
+
+ var folder = await picker.PickSingleFolderAsync();
+ if (folder is not null)
+ {
+ ViewModel.TargetFolder = folder.Path;
+ }
+ }
+
+ private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ string root = ViewModel.EffectiveTargetRoot;
+ if (Directory.Exists(root))
+ {
+ await Windows.System.Launcher.LaunchFolderPathAsync(root);
+ }
+ }
+ }
+}