Toolbar clipping fix, header filters, review fixes (beta.11) #34
No files matched your search
@@ -37,6 +37,26 @@ dotnet test gclo.Engine.Tests
|
||||
dotnet format gclo.slnx --verify-no-changes --severity error
|
||||
```
|
||||
|
||||
## Debugging in Visual Studio: expect (and silence) exception breaks
|
||||
|
||||
Per-repo failure isolation is exception-based by design: a repository that
|
||||
cannot be cloned or pulled throws (`LibGit2SharpException`,
|
||||
`InvalidRepositoryPathsException`, ...) and the sync engine catches it, marks
|
||||
that row Failed, and keeps going. Many of these exceptions surface from inside
|
||||
native libgit2 frames, so with **Just My Code** enabled Visual Studio breaks on
|
||||
them as "user-unhandled" even though they are always caught — during a sync
|
||||
with failing repositories the debugger pauses every thread on each one, which
|
||||
looks like the whole UI is frozen until you press Continue.
|
||||
|
||||
None of this happens outside the debugger. Options, in order of preference:
|
||||
|
||||
1. Run without the debugger (**Ctrl+F5**) unless you are actively debugging.
|
||||
2. In **Debug → Windows → Exception Settings**, uncheck *Break when this
|
||||
exception type is user-unhandled* for `LibGit2Sharp.LibGit2SharpException`
|
||||
(and `gclo.Engine.InvalidRepositoryPathsException`) after the first break.
|
||||
3. Disable **Tools → Options → Debugging → Enable Just My Code** — the
|
||||
debugger then only breaks on genuinely unhandled exceptions.
|
||||
|
||||
## What CI enforces
|
||||
|
||||
Every pull request must pass:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the security contract of <see cref="SyncRequest"/>: the record ships on
|
||||
/// NuGet, and its ToString must never print the PAT (the generated record
|
||||
/// ToString would include every positional member).
|
||||
/// </summary>
|
||||
public sealed class SyncRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToString_NeverContainsTheToken()
|
||||
{
|
||||
var request = new SyncRequest("acme", "ghp_super_secret_token", @"C:\src", 8);
|
||||
|
||||
string text = request.ToString();
|
||||
|
||||
Assert.DoesNotContain("ghp_super_secret_token", text);
|
||||
Assert.Contains("[redacted]", text);
|
||||
Assert.Contains("acme", text); // everything else stays diagnosable
|
||||
}
|
||||
}
|
||||
@@ -910,6 +910,75 @@ public sealed class WorkspaceViewModelTests
|
||||
Assert.NotEmpty(vm.Repos); // the empty-filter hint case: rows exist, none match
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameFilter_CaseInsensitiveSubstring_ComposesWithStatusFilter()
|
||||
{
|
||||
var vm = await CreateLoadedViewModelAsync(
|
||||
Repo("platform-api"), Repo("platform-web"), Repo("tools"));
|
||||
vm.Repos.First(r => r.Name == "platform-api").Status = SyncStatus.Failed;
|
||||
|
||||
vm.NameFilter = "PLATFORM";
|
||||
Assert.Equal(["platform-api", "platform-web"], vm.FilteredRepos.Select(r => r.Name));
|
||||
|
||||
vm.Filter = RepoFilter.Failed; // AND-composes with the name filter
|
||||
Assert.Equal(["platform-api"], vm.FilteredRepos.Select(r => r.Name));
|
||||
|
||||
vm.NameFilter = "";
|
||||
vm.Filter = RepoFilter.All;
|
||||
Assert.Equal(3, vm.FilteredRepos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameFilter_TrimsBeforeMatching()
|
||||
{
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
|
||||
|
||||
vm.NameFilter = " alpha ";
|
||||
|
||||
Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BranchFilter_MatchesTheDisplayedBranchText()
|
||||
{
|
||||
var vm = await CreateLoadedViewModelAsync(
|
||||
Repo("alpha", branch: "main"),
|
||||
Repo("bravo", branch: "develop"),
|
||||
Repo("empty", branch: null)); // empty repository: BranchText is ""
|
||||
|
||||
vm.BranchFilter = "dev";
|
||||
|
||||
Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchivedFilter_TriState_AllArchivedUnarchived()
|
||||
{
|
||||
var vm = await CreateLoadedViewModelAsync(
|
||||
Repo("alpha", archived: true), Repo("bravo"));
|
||||
|
||||
vm.ArchivedFilter = true;
|
||||
Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name));
|
||||
|
||||
vm.ArchivedFilter = false;
|
||||
Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name));
|
||||
|
||||
vm.ArchivedFilter = null;
|
||||
Assert.Equal(2, vm.FilteredRepos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ColumnFilters_NeverTouchSelection_HiddenRowsStillSync()
|
||||
{
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
|
||||
Assert.True(vm.Repos.All(r => r.IsSelected)); // load selects everything
|
||||
|
||||
vm.NameFilter = "alpha"; // bravo is hidden but stays selected
|
||||
|
||||
Assert.Equal(2, vm.SelectedCount);
|
||||
Assert.Contains("2", vm.SyncButtonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilteredRepos_FollowsTerminalTransitions_AcrossARun()
|
||||
{
|
||||
|
||||
@@ -5,4 +5,14 @@ namespace gclo.Engine;
|
||||
/// <param name="Token">GitHub Personal Access Token used for both the API and git transport.</param>
|
||||
/// <param name="TargetRoot">Local folder under which each repository is placed as a subfolder.</param>
|
||||
/// <param name="MaxConcurrency">Upper bound on simultaneous git operations.</param>
|
||||
public sealed record SyncRequest(string Organization, string Token, string TargetRoot, int MaxConcurrency = 8);
|
||||
public sealed record SyncRequest(string Organization, string Token, string TargetRoot, int MaxConcurrency = 8)
|
||||
{
|
||||
/// <summary>
|
||||
/// Redacts the token: the record-generated ToString would print every positional
|
||||
/// member, and this type ships publicly on NuGet where any log or exception
|
||||
/// interpolation of a request would otherwise leak the PAT.
|
||||
/// </summary>
|
||||
public override string ToString() =>
|
||||
$"SyncRequest {{ Organization = {Organization}, Token = [redacted], "
|
||||
+ $"TargetRoot = {TargetRoot}, MaxConcurrency = {MaxConcurrency} }}";
|
||||
}
|
||||
@@ -74,6 +74,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
Organization = "";
|
||||
Token = "";
|
||||
TargetFolder = "";
|
||||
NameFilter = "";
|
||||
BranchFilter = "";
|
||||
MaxConcurrency = AppSettings.DefaultConcurrency;
|
||||
StatusText = "";
|
||||
ResultMessage = "";
|
||||
@@ -196,6 +198,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial RepoFilter Filter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Column filter: case-insensitive substring the repository name must contain.
|
||||
/// Empty (the default) matches everything. Composes with <see cref="Filter"/>
|
||||
/// and the other column filters; selection state is unaffected — hidden rows
|
||||
/// stay selected and still sync.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial string NameFilter { get; set; }
|
||||
|
||||
/// <summary>Column filter: case-insensitive substring the branch name must contain.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string BranchFilter { get; set; }
|
||||
|
||||
/// <summary>Column filter: null shows all, true only archived, false only unarchived.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool? ArchivedFilter { get; set; }
|
||||
|
||||
/// <summary>Number of rows currently selected for the next sync.</summary>
|
||||
[ObservableProperty]
|
||||
public partial int SelectedCount { get; set; }
|
||||
@@ -231,6 +250,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
partial void OnFilterChanged(RepoFilter value) => RebuildFilteredRepos();
|
||||
|
||||
partial void OnNameFilterChanged(string value) => RebuildFilteredRepos();
|
||||
|
||||
partial void OnBranchFilterChanged(string value) => RebuildFilteredRepos();
|
||||
|
||||
partial void OnArchivedFilterChanged(bool? value) => RebuildFilteredRepos();
|
||||
|
||||
partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(SyncButtonLabel));
|
||||
|
||||
partial void OnIsRunningChanged(bool value) => UpdateCanEditInputs();
|
||||
@@ -861,7 +886,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private bool MatchesFilter(RepoItemViewModel repo) => Filter switch
|
||||
private bool MatchesFilter(RepoItemViewModel repo)
|
||||
=> MatchesStatusFilter(repo)
|
||||
&& (NameFilter.Length == 0
|
||||
|| repo.Name.Contains(NameFilter.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
&& (BranchFilter.Length == 0
|
||||
|| repo.BranchText.Contains(BranchFilter.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
&& (ArchivedFilter is not { } archived || repo.IsArchived == archived);
|
||||
|
||||
private bool MatchesStatusFilter(RepoItemViewModel repo) => Filter switch
|
||||
{
|
||||
RepoFilter.Active => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling,
|
||||
RepoFilter.Failed => repo.Status == SyncStatus.Failed,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using gclo.Engine;
|
||||
using Velopack;
|
||||
using Velopack.Sources;
|
||||
|
||||
@@ -122,7 +124,21 @@ public sealed class UpdateService
|
||||
/// <summary>
|
||||
/// Created lazily so merely constructing the service can never fail; callers wrap this
|
||||
/// in try/catch and treat a throwing manager as "updates not supported".
|
||||
/// A prerelease install (semver has a '-suffix') runs on the dev channel, whose
|
||||
/// packages are attached to GitHub *prerelease* releases — GithubSource must be
|
||||
/// told to look at prereleases or dev installs can never find any update at all.
|
||||
/// Stable installs keep prereleases out of consideration; Velopack's channel
|
||||
/// filtering then matches packages to the installed channel either way.
|
||||
/// </summary>
|
||||
private UpdateManager GetManager() =>
|
||||
_manager ??= new UpdateManager(new GithubSource(RepoUrl, null, false));
|
||||
private UpdateManager GetManager()
|
||||
{
|
||||
if (_manager is null)
|
||||
{
|
||||
bool prerelease = BuildVersion
|
||||
.Describe(Assembly.GetExecutingAssembly())
|
||||
.Contains('-');
|
||||
_manager = new UpdateManager(new GithubSource(RepoUrl, null, prerelease));
|
||||
}
|
||||
return _manager;
|
||||
}
|
||||
}
|
||||
+292
-106
@@ -9,12 +9,54 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- Sortable column header: quiet button, muted semibold label; left padding
|
||||
stays 0 so header text aligns exactly with the row cells below. -->
|
||||
<Style x:Key="ColumnHeaderButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0,4,4,4" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
<!-- The per-column funnel that opens that column's filter flyout. -->
|
||||
<Style x:Key="ColumnFilterButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Padding="20" RowSpacing="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<!-- Driven by UpdateToolbarLayout (measured widths, not window width, so
|
||||
text scaling and localization count too). The markup default is the
|
||||
narrow two-row toolbar; this state lifts the action buttons back
|
||||
beside the filter bar when everything fits on one row. -->
|
||||
<VisualStateGroup x:Name="ToolbarLayoutStates">
|
||||
<VisualState x:Name="ToolbarTwoRow" />
|
||||
<VisualState x:Name="ToolbarSingleRow">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="IdleActions.(Grid.Row)" Value="0" />
|
||||
<Setter Target="IdleActions.(Grid.Column)" Value="2" />
|
||||
<Setter Target="IdleActions.(Grid.ColumnSpan)" Value="1" />
|
||||
<Setter Target="IdleActions.Margin" Value="0" />
|
||||
<Setter Target="RunningActions.(Grid.Row)" Value="0" />
|
||||
<Setter Target="RunningActions.(Grid.Column)" Value="2" />
|
||||
<Setter Target="RunningActions.(Grid.ColumnSpan)" Value="1" />
|
||||
<Setter Target="RunningActions.Margin" Value="0" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
|
||||
<!-- ============================================================ State 1
|
||||
Connect card, shown until the first successful load in this workspace. -->
|
||||
<ScrollViewer
|
||||
@@ -281,11 +323,22 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Toolbar: filter | selection summary | spacer | options / refresh / sync,
|
||||
with the right side swapping to live progress + Cancel while running. -->
|
||||
<Grid Grid.Row="0" ColumnSpacing="12">
|
||||
<!-- Toolbar: filter | selection summary (trims) | options / refresh / sync,
|
||||
with the right side swapping to live progress + Cancel while running.
|
||||
The XAML default is the narrow layout (actions on their own row); the
|
||||
ToolbarSingleRow visual state lifts them beside the filter when the
|
||||
measured widths fit — see UpdateToolbarLayout, which responds to any
|
||||
width pressure (window size, text scaling, localization). -->
|
||||
<Grid
|
||||
x:Name="ToolbarGrid"
|
||||
Grid.Row="0"
|
||||
ColumnSpacing="12"
|
||||
SizeChanged="Toolbar_SizeChanged">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -305,11 +358,18 @@
|
||||
x:Name="SelectionSummaryText"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<StackPanel
|
||||
Grid.Column="3"
|
||||
x:Name="IdleActions"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
SizeChanged="Toolbar_SizeChanged"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}">
|
||||
<Button
|
||||
x:Name="OptionsButton"
|
||||
@@ -367,12 +427,18 @@
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Grid.Column="3"
|
||||
x:Name="RunningActions"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
SizeChanged="Toolbar_SizeChanged"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsRunning), Mode=OneWay}">
|
||||
<ProgressBar
|
||||
Width="160"
|
||||
Width="120"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="Overall sync progress"
|
||||
Maximum="{x:Bind ViewModel.TotalCount, Mode=OneWay}"
|
||||
@@ -462,105 +528,225 @@
|
||||
</InfoBar>
|
||||
|
||||
<!-- Table header: fixed column widths mirrored by the row template below.
|
||||
Sort arrows are visual-only; each button carries a composed
|
||||
automation name ("Name, sorted ascending"). -->
|
||||
<Grid Grid.Row="3" Margin="0,8,0,0" Padding="12,0,12,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="80" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTipService.ToolTip="Select all"
|
||||
AutomationProperties.Name="Select all repositories"
|
||||
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Name">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Name" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Name', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Status">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Status" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Status', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Branch">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Branch" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Branch', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Archived">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Archived" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Archived', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
Every column sorts (button, composed automation name like
|
||||
"Name, sorted ascending") and filters (funnel flyout; the funnel
|
||||
turns accent-colored while its filter is active). The header row
|
||||
itself gets subtle chrome to read as a header, not a data row. -->
|
||||
<Border
|
||||
Grid.Row="3"
|
||||
Margin="0,8,0,0"
|
||||
Padding="12,2,12,2"
|
||||
Background="{ThemeResource SubtleFillColorSecondaryBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
CornerRadius="4,4,0,0">
|
||||
<Grid>
|
||||
<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}" />
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Name">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Name" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Name', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="NameFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('name', ViewModel.NameFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by name">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.NameFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Name contains"
|
||||
AutomationProperties.AutomationId="NameFilterBox"
|
||||
Text="{x:Bind ViewModel.NameFilter, Mode=OneWay}"
|
||||
TextChanged="NameFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearNameFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Status">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Status" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Status', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="StatusFilterButton"
|
||||
AutomationProperties.Name="{x:Bind StatusFilterAutomationName(ViewModel.Filter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by status">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind StatusFilterIndicatorBrush(ViewModel.Filter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="StatusFilterChoices"
|
||||
SelectedIndex="{x:Bind StatusFilterIndex(ViewModel.Filter), Mode=OneWay}"
|
||||
SelectionChanged="StatusFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Active</x:String>
|
||||
<x:String>Failed</x:String>
|
||||
<x:String>Pending</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Branch">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Branch" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Branch', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="BranchFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('branch', ViewModel.BranchFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by branch">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.BranchFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Branch contains"
|
||||
AutomationProperties.AutomationId="BranchFilterBox"
|
||||
Text="{x:Bind ViewModel.BranchFilter, Mode=OneWay}"
|
||||
TextChanged="BranchFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearBranchFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Archived">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Archived" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Archived', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="ArchivedFilterButton"
|
||||
AutomationProperties.Name="{x:Bind ArchivedFilterAutomationName(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by archived">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind ArchivedFilterIndicatorBrush(ViewModel.ArchivedFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="ArchivedFilterChoices"
|
||||
SelectedIndex="{x:Bind ArchivedFilterIndex(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
SelectionChanged="ArchivedFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Archived only</x:String>
|
||||
<x:String>Not archived</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Per-repo table body. The ListView carries the view model as DataContext so
|
||||
the row template's Resolve link can reach WorkspaceViewModel.ResolvePathsCommand
|
||||
|
||||
@@ -123,6 +123,20 @@ namespace gclo
|
||||
{
|
||||
UpdateDerivedTexts();
|
||||
}
|
||||
else if (e.PropertyName == nameof(WorkspaceViewModel.Filter))
|
||||
{
|
||||
// The status filter can now change from the header flyout too; keep
|
||||
// the toolbar chips in agreement. Selecting the already-selected item
|
||||
// (or the echo from the chips' own handler) is a no-op.
|
||||
foreach (SelectorBarItem item in FilterSelectorBar.Items)
|
||||
{
|
||||
if (item.Tag is string tag && tag == ViewModel.Filter.ToString())
|
||||
{
|
||||
FilterSelectorBar.SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -149,6 +163,109 @@ namespace gclo
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- column filters
|
||||
//
|
||||
// The flyout inputs bind OneWay from the view model and write back through
|
||||
// these handlers; the view model setters no-op on equal values, so the echo
|
||||
// from a programmatic Text/SelectedIndex update never loops.
|
||||
|
||||
private void NameFilterBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
=> ViewModel.NameFilter = ((TextBox)sender).Text;
|
||||
|
||||
private void ClearNameFilter_Click(object sender, RoutedEventArgs e)
|
||||
=> ViewModel.NameFilter = "";
|
||||
|
||||
private void BranchFilterBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
=> ViewModel.BranchFilter = ((TextBox)sender).Text;
|
||||
|
||||
private void ClearBranchFilter_Click(object sender, RoutedEventArgs e)
|
||||
=> ViewModel.BranchFilter = "";
|
||||
|
||||
private void StatusFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (((RadioButtons)sender).SelectedIndex is >= 0 and int index)
|
||||
{
|
||||
ViewModel.Filter = (RepoFilter)index; // list order mirrors the enum
|
||||
}
|
||||
}
|
||||
|
||||
private void ArchivedFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
ViewModel.ArchivedFilter = ((RadioButtons)sender).SelectedIndex switch
|
||||
{
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Flyout radio index for the status filter; list order mirrors the enum.</summary>
|
||||
public int StatusFilterIndex(RepoFilter filter) => (int)filter;
|
||||
|
||||
/// <summary>Flyout radio index for the archived filter: All / Archived only / Not archived.</summary>
|
||||
public int ArchivedFilterIndex(bool? filter) => filter switch
|
||||
{
|
||||
true => 1,
|
||||
false => 2,
|
||||
null => 0,
|
||||
};
|
||||
|
||||
/// <summary>Funnel tint: accent while that column's filter is active.</summary>
|
||||
public Microsoft.UI.Xaml.Media.Brush TextFilterIndicatorBrush(string filter)
|
||||
=> FilterIndicatorBrush(filter.Length > 0);
|
||||
|
||||
public Microsoft.UI.Xaml.Media.Brush StatusFilterIndicatorBrush(RepoFilter filter)
|
||||
=> FilterIndicatorBrush(filter != RepoFilter.All);
|
||||
|
||||
public Microsoft.UI.Xaml.Media.Brush ArchivedFilterIndicatorBrush(bool? filter)
|
||||
=> FilterIndicatorBrush(filter is not null);
|
||||
|
||||
private static Microsoft.UI.Xaml.Media.Brush FilterIndicatorBrush(bool active)
|
||||
=> (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[
|
||||
active ? "AccentTextFillColorPrimaryBrush" : "TextFillColorSecondaryBrush"];
|
||||
|
||||
/// <summary>Composed automation name for a text-filter funnel, carrying its state.</summary>
|
||||
public string TextFilterAutomationName(string column, string filter)
|
||||
=> filter.Length == 0
|
||||
? $"Filter by {column}"
|
||||
: $"Filter by {column}, filtering on '{filter}'";
|
||||
|
||||
public string StatusFilterAutomationName(RepoFilter filter)
|
||||
=> filter == RepoFilter.All
|
||||
? "Filter by status"
|
||||
: $"Filter by status, showing {filter}";
|
||||
|
||||
public string ArchivedFilterAutomationName(bool? filter) => filter switch
|
||||
{
|
||||
true => "Filter by archived, showing archived only",
|
||||
false => "Filter by archived, showing not archived",
|
||||
null => "Filter by archived",
|
||||
};
|
||||
|
||||
private void Toolbar_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
=> UpdateToolbarLayout();
|
||||
|
||||
/// <summary>
|
||||
/// Picks the toolbar layout from measured widths: the action buttons sit beside
|
||||
/// the filter bar only when both fit on one row, otherwise they drop to their
|
||||
/// own right-aligned row. Measuring (instead of a window-width trigger) makes
|
||||
/// this respond to every source of width pressure — window size, Windows text
|
||||
/// scaling, localization, and count-dependent label growth. The idle/running
|
||||
/// action groups swap by visibility, so the wider of the two is what must fit;
|
||||
/// a collapsed group keeps its last arranged width, which is exactly the width
|
||||
/// it will need when it comes back.
|
||||
/// </summary>
|
||||
private void UpdateToolbarLayout()
|
||||
{
|
||||
// Two ColumnSpacing gaps (filter | summary | actions); the summary itself
|
||||
// may trim to nothing, so it claims no reserved width here.
|
||||
double gaps = ToolbarGrid.ColumnSpacing * 2;
|
||||
double actions = Math.Max(IdleActions.ActualWidth, RunningActions.ActualWidth);
|
||||
bool fits = FilterSelectorBar.ActualWidth + gaps + actions <= ToolbarGrid.ActualWidth;
|
||||
VisualStateManager.GoToState(
|
||||
this, fits ? "ToolbarSingleRow" : "ToolbarTwoRow", useTransitions: false);
|
||||
}
|
||||
|
||||
// The chip's Edit link opens its attached flyout (HyperlinkButton has no
|
||||
// Flyout property of its own).
|
||||
private void EditButton_Click(object sender, RoutedEventArgs e)
|
||||
|
||||
Reference in new issue
Block a user