Phase 2: NavigationView shell with per-account workspaces #26

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-04 20:49:04 +00:00
7 changed files with 1016 additions and 462 deletions

No files matched your search

@@ -5,11 +5,11 @@ using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Headless tests for <see cref="MainViewModel"/> using a synchronous progress factory
/// Headless tests for <see cref="WorkspaceViewModel"/> using a synchronous progress factory
/// (the production default, <see cref="Progress{T}"/>, posts asynchronously and would
/// make assertions racy) and a near-zero org-lookup debounce.
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
private async Task<MainViewModel> CreateLoadedViewModelAsync(params RepoDescriptor[] repos)
private async Task<WorkspaceViewModel> 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
/// <summary>An account profile whose repositories land under <paramref name="targetRoot"/>.</summary>
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<IReadOnlyList<string>>(["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]
@@ -7,11 +7,14 @@ using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Drives the org-sync UI in two phases: <see cref="LoadReposCommand"/> fills the repository
/// table, then <see cref="SyncCommand"/> clones or updates the selected subset with live
/// per-repo progress. <see cref="RetryFailedCommand"/> re-runs just the failures.
/// Drives one workspace of the org-sync UI in two phases: <see cref="LoadReposCommand"/>
/// fills the repository table, then <see cref="SyncCommand"/> clones or updates the
/// selected subset with live per-repo progress. <see cref="RetryFailedCommand"/> re-runs
/// just the failures. A workspace is either backed by a saved <see cref="Account"/>
/// (seeded from its profile, with each finished sync recorded back to the
/// <see cref="AccountsStore"/>) or the ad-hoc Quick Sync mode when constructed without one.
/// </summary>
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<Action<RepoProgress>, IProgress<RepoProgress>> _progressFactory;
private readonly TimeSpan _orgLookupDebounce;
private readonly IActivityLog _log;
private readonly Account? _account;
private readonly AccountsStore? _accountsStore;
private readonly Dictionary<string, RepoItemViewModel> _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 <see cref="Progress{T}"/>, which marshals via the SynchronizationContext
/// captured at construction (the UI thread in the app); tests inject a synchronous one.
/// A non-null <paramref name="account"/> seeds the workspace from that profile — its
/// organization, target folder, subfolder preference, and concurrency — and pulls the
/// token from <paramref name="tokenVault"/> (the Token setter's org lookup fires
/// naturally); finished syncs are then recorded on <paramref name="accountsStore"/>.
/// </summary>
public MainViewModel(
public WorkspaceViewModel(
IRepositoryLister? lister = null,
IGitClient? git = null,
IOrganizationLister? orgLister = null,
Func<Action<RepoProgress>, IProgress<RepoProgress>>? 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<RepoProgress>(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) ?? "";
}
}
/// <summary>Id of the account this workspace was created for, or null for Quick Sync.</summary>
public Guid? AccountId => _account?.Id;
/// <summary>Name shown for this workspace in navigation: the account's name, or "Quick Sync".</summary>
public string DisplayName => _account?.Name ?? "Quick Sync";
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
/// <summary>Organizations discovered from the current token; feeds the org dropdown.</summary>
@@ -111,6 +141,10 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool HasCompletedRun { get; set; }
/// <summary>True while any row is in the Failed state; drives the navigation badge.</summary>
[ObservableProperty]
public partial bool HasFailedRepos { get; set; }
/// <summary>Column the table is currently sorted by, or null for load order.</summary>
[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);
}
+27 -311
View File
@@ -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 @@
</MenuBarItem>
</MenuBar>
<Grid Grid.Row="1" 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="*" />
</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
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"
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>
<!-- 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 MainViewModel.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}" />
<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>
</Grid>
<!-- Workspace shell: Quick Sync is pinned first; one item per saved account is
added below the separator in code-behind (the store is runtime data).
Content hosts the selected workspace's cached WorkspacePage. -->
<NavigationView
x:Name="WorkspaceNav"
Grid.Row="1"
PaneDisplayMode="Left"
IsBackButtonVisible="Collapsed"
IsSettingsVisible="False"
OpenPaneLength="240"
SelectionChanged="WorkspaceNav_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem x:Name="QuickSyncNavItem" Content="Quick Sync">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE945;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItemSeparator />
</NavigationView.MenuItems>
<NavigationView.FooterMenuItems>
<NavigationViewItem Content="Add account" Tag="add">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE710;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
</NavigationView.FooterMenuItems>
</NavigationView>
</Grid>
</Window>
+238 -137
View File
@@ -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
{
/// <summary>
/// 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
/// <see cref="WorkspacePage"/> and its <see cref="WorkspaceViewModel"/>) 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.
/// </summary>
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; }
/// <summary>Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync).</summary>
private readonly Dictionary<Guid, (WorkspaceViewModel ViewModel, WorkspacePage Page)> _workspaces = new();
/// <summary>Badge subscriptions per workspace, unhooked before the view models are disposed.</summary>
private readonly Dictionary<Guid, PropertyChangedEventHandler> _badgeHandlers = new();
/// <summary>Navigation items by workspace id, for badge and tooltip refreshes.</summary>
private readonly Dictionary<Guid, NavigationViewItem> _navItems = new();
/// <summary>The workspace the pane currently rests on; where 'Add account' reverts to.</summary>
private Guid _currentWorkspaceId = Guid.Empty;
/// <summary>Suppresses re-entrant SelectionChanged while the code puts a selection back.</summary>
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;
}
/// <summary>
/// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
/// </summary>
public string OrgSubfolderLabel(string organization)
=> string.IsNullOrWhiteSpace(organization)
? "Create org subfolder"
: $"Create {organization.Trim()} subfolder";
// ---------------------------------------------------------------- workspaces
/// <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>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Puts the workspace for <paramref name="id"/> on screen, creating and caching
/// it on first visit. Cached view models keep running syncs alive across switches.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </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");
}
}
}
/// <summary>
/// A value-less (dot) InfoBadge, styled by the WinUI dot-badge resource when
/// present, otherwise tinted directly from the matching theme fill brush.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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
}
}
/// <summary>
/// Shows <see cref="PathRecoveryDialog"/> 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).
/// </summary>
private async Task<PathRecovery?> 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);
}
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ using Microsoft.UI.Xaml.Media;
namespace gclo;
/// <summary>Static helpers for x:Bind function bindings in MainWindow.xaml.</summary>
/// <summary>Static helpers for x:Bind function bindings in WorkspacePage.xaml.</summary>
public static class StatusFormat
{
/// <summary>
+323
View File
@@ -0,0 +1,323 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="gclo.WorkspacePage"
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">
<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="*" />
</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"
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>
<!-- 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}" />
<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>
</Grid>
</UserControl>
+174
View File
@@ -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
{
/// <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 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.
/// </summary>
public sealed partial class WorkspacePage : UserControl
{
// A UserControl has no HWND of its own; the host window supplies one for pickers.
private readonly Func<nint> _windowHandleProvider;
/// <summary>The workspace state this page renders; target of every x:Bind.</summary>
public WorkspaceViewModel ViewModel { get; }
/// <summary>
/// Wires the page to its (caller-owned) view model. The
/// <paramref name="windowHandleProvider"/> returns the host window's HWND,
/// needed to initialize the folder picker.
/// </summary>
public WorkspacePage(WorkspaceViewModel viewModel, Func<nint> 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;
}
}
/// <summary>
/// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
/// </summary>
public string OrgSubfolderLabel(string organization)
=> string.IsNullOrWhiteSpace(organization)
? "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>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// Shows <see cref="PathRecoveryDialog"/> 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).
/// </summary>
private async Task<PathRecovery?> 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);
}
}
}
}