Phase 2: NavigationView shell with per-account workspaces #26
No files matched your search
+193
-7
@@ -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
@@ -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="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItemSeparator />
|
||||
</NavigationView.MenuItems>
|
||||
<NavigationView.FooterMenuItems>
|
||||
<NavigationViewItem Content="Add account" Tag="add">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
</NavigationView.FooterMenuItems>
|
||||
</NavigationView>
|
||||
</Grid>
|
||||
</Window>
|
||||
+238
-137
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user