feat(ui): collapsible folders for the list sidebar

Folders render above the top-level lists, each with its own collapsible
children; tapping a folder header folds it instead of selecting it.
Lists move in by dragging onto a folder header (or onto a row inside it)
and out by dropping between top-level rows or via the context menu.

Creating a folder is a toggle in the New-list modal, not a third sidebar
button: it reduces the modal to name + icon and writes a folder row
instead of the list row the create flow pre-created.
This commit is contained in:
mika kuns
2026-09-10 08:29:56 +02:00
parent 13e860f06b
commit 25c9a619dc
9 changed files with 522 additions and 95 deletions
@@ -5,6 +5,91 @@
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel">
<UserControl.Resources>
<!-- One user-list row. Shared by the top-level list and by each folder's children, so the
row (context menu, drag handlers, drop indicators) exists once. -->
<DataTemplate x:Key="UserListRow" DataType="vm:ListNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextRemoveFromFolder}"
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).RemoveFromFolderCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</UserControl.Resources>
<DockPanel LastChildFill="True">
<!-- ── Header ── -->
@@ -133,87 +218,61 @@
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding UserLists}">
<!-- Folders first, each with its own (collapsible) child lists, then the top-level lists.
Folders are never selectable — tapping the header folds it. -->
<ItemsControl ItemsSource="{Binding Folders}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<DataTemplate DataType="vm:ListFolderNavItemViewModel">
<StackPanel>
<Border Classes="list-item"
Classes.drop-target="{Binding IsDropTarget}"
Tapped="OnFolderTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnFolderDragOver"
DragDrop.Drop="OnFolderDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextFolderSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Lists}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"
ItemTemplate="{StaticResource UserListRow}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding RootLists}"
ItemTemplate="{StaticResource UserListRow}"/>
</StackPanel>
</ScrollViewer>
</DockPanel>