Repo table: styled header row + per-column filtering (name/status/branch/archived)
The header now reads as a header: subtle fill, bottom divider, muted semibold labels via a shared ColumnHeaderButtonStyle instead of four copies of inline styling. Each sortable column gains a funnel button opening a filter flyout — substring boxes for Name and Branch, radio choices for Status (kept in lockstep with the toolbar chips both ways) and Archived (all/only/not). Active filters tint their funnel accent and carry the state in the automation name. Filtering logic lives in WorkspaceViewModel (NameFilter/BranchFilter/ ArchivedFilter compose by AND with the status filter; selection is untouched, so hidden rows still sync) with five new headless tests. Verified live against a real 269-repo load: filtering to a non-match empties the table and shows the empty-filter hint. Name filtering also addresses the review's P1 "no repository name search" finding at its first level (#30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb153f35ba
commit
53a230f40a
4 files changed
+435
-100
No files matched your search
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
+239
-99
@@ -9,6 +9,26 @@
|
||||
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="*" />
|
||||
@@ -508,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,85 @@ 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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user