Toolbar clipping fix, header filters, review fixes (beta.11) #34

Merged
KofTwentyTwo merged 4 commits from dev into main 2026-07-05 12:33:19 +00:00
8 changed files with 582 additions and 110 deletions

No files matched your search

+20
View File
@@ -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:
+21
View File
@@ -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()
{
+11 -1
View File
@@ -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} }}";
}
+34 -1
View File
@@ -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,
+18 -2
View File
@@ -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
View File
@@ -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="&#xE71C;"
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="&#xE71C;"
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="&#xE71C;"
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="&#xE71C;"
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
+117
View File
@@ -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)