feat(ui): make sidebar folder hierarchy readable
Lists default to the Text icon instead of the folder icon, rows align across depths with a guide line per open folder, folder names are semibold, folder counts only show while collapsed and zero counts hide.
This commit is contained in:
@@ -16,9 +16,16 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
|||||||
public string? ParentFolderId { get; set; }
|
public string? ParentFolderId { get; set; }
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _iconKey = "Folder";
|
[ObservableProperty] private string _iconKey = "Folder";
|
||||||
[ObservableProperty] private bool _isCollapsed;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||||
|
private bool _isCollapsed;
|
||||||
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
|
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
|
||||||
[ObservableProperty] private int _count;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
/// <summary>Badge only while collapsed — expanded, the children show their own counts.</summary>
|
||||||
|
public bool ShowCount => IsCollapsed && Count > 0;
|
||||||
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
||||||
[ObservableProperty] private bool _isDropTarget;
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required ListKind Kind { get; init; }
|
public required ListKind Kind { get; init; }
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private int _count;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasCount))]
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
/// <summary>Hides a "0" badge — an empty list reads as noise next to the real counts.</summary>
|
||||||
|
public bool HasCount => Count > 0;
|
||||||
[ObservableProperty] private bool _isActive;
|
[ObservableProperty] private bool _isActive;
|
||||||
[ObservableProperty] private string? _workingDir;
|
[ObservableProperty] private string? _workingDir;
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
||||||
public const string ListFolderIcon = "Folder";
|
public const string ListFolderIcon = "Folder";
|
||||||
|
|
||||||
|
/// <summary>Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.</summary>
|
||||||
|
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
private readonly IWorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
@@ -368,7 +371,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{l.Id}",
|
Id = $"user:{l.Id}",
|
||||||
Name = l.Name,
|
Name = l.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = l.Icon ?? "Folder",
|
IconKey = l.Icon ?? ListIcon,
|
||||||
WorkingDir = l.WorkingDir,
|
WorkingDir = l.WorkingDir,
|
||||||
DefaultCommitType = l.DefaultCommitType,
|
DefaultCommitType = l.DefaultCommitType,
|
||||||
IsManual = l.IsManual,
|
IsManual = l.IsManual,
|
||||||
@@ -473,7 +476,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{entity.Id}",
|
Id = $"user:{entity.Id}",
|
||||||
Name = entity.Name,
|
Name = entity.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = "Folder",
|
IconKey = ListIcon,
|
||||||
WorkingDir = entity.WorkingDir,
|
WorkingDir = entity.WorkingDir,
|
||||||
DefaultCommitType = entity.DefaultCommitType,
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
};
|
};
|
||||||
@@ -840,7 +843,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
row.IsManual = entity.IsManual;
|
row.IsManual = entity.IsManual;
|
||||||
row.FindingsTracked = entity.FindingsTracked;
|
row.FindingsTracked = entity.FindingsTracked;
|
||||||
row.IconKey = entity.Icon ?? "Folder";
|
row.IconKey = entity.Icon ?? ListIcon;
|
||||||
row.HasTicketProject = hasTicketProject;
|
row.HasTicketProject = hasTicketProject;
|
||||||
RecomputeHasNoLinkedRepo();
|
RecomputeHasNoLinkedRepo();
|
||||||
}
|
}
|
||||||
@@ -886,7 +889,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{entity.Id}",
|
Id = $"user:{entity.Id}",
|
||||||
Name = entity.Name,
|
Name = entity.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = entity.Icon ?? "Folder",
|
IconKey = entity.Icon ?? ListIcon,
|
||||||
WorkingDir = entity.WorkingDir,
|
WorkingDir = entity.WorkingDir,
|
||||||
DefaultCommitType = entity.DefaultCommitType,
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
IsManual = entity.IsManual,
|
IsManual = entity.IsManual,
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
public const string DefaultIcon = "Folder";
|
public const string DefaultIcon = "Text";
|
||||||
|
|
||||||
|
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
|
||||||
|
public const string DefaultFolderIcon = "Folder";
|
||||||
|
|
||||||
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
|
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
|
||||||
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
|
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
|
||||||
@@ -171,7 +174,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
IsFolder = true;
|
IsFolder = true;
|
||||||
CanToggleFolder = false;
|
CanToggleFolder = false;
|
||||||
Name = name;
|
Name = name;
|
||||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
Icon = IconOptions.Contains(icon) ? icon! : DefaultFolderIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(
|
public async Task LoadAsync(
|
||||||
@@ -261,6 +264,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
||||||
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
||||||
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
||||||
|
// The create-flow toggle swaps the untouched default so a new folder doesn't get the list icon.
|
||||||
|
partial void OnIsFolderChanged(bool value)
|
||||||
|
{
|
||||||
|
if (Icon == (value ? DefaultIcon : DefaultFolderIcon))
|
||||||
|
Icon = value ? DefaultFolderIcon : DefaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnWorkingDirChanged(string value)
|
partial void OnWorkingDirChanged(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
||||||
|
|||||||
@@ -57,9 +57,11 @@
|
|||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="20,*,Auto">
|
<!-- Empty leading column = the folder header's chevron slot, so list and folder icons
|
||||||
|
at the same depth line up. -->
|
||||||
|
<Grid ColumnDefinitions="12,20,*,Auto">
|
||||||
<!-- Left accent bar for active state -->
|
<!-- Left accent bar for active state -->
|
||||||
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
<Border Grid.Column="0" Grid.ColumnSpan="4"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
CornerRadius="8" IsHitTestVisible="False"
|
CornerRadius="8" IsHitTestVisible="False"
|
||||||
IsVisible="{Binding IsActive}">
|
IsVisible="{Binding IsActive}">
|
||||||
@@ -70,18 +72,18 @@
|
|||||||
Margin="-8,0,0,0"/>
|
Margin="-8,0,0,0"/>
|
||||||
</Border>
|
</Border>
|
||||||
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
|
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
|
||||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
<PathIcon Grid.Column="1" Classes="list-icon"
|
||||||
Width="14" Height="14"
|
Width="14" Height="14"
|
||||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<TextBlock Grid.Column="1" Classes="list-label"
|
<TextBlock Grid.Column="2" Classes="list-label"
|
||||||
Text="{Binding Name}"
|
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
|
||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<TextBlock Grid.Column="2" Classes="list-count"
|
<TextBlock Grid.Column="3" Classes="list-count"
|
||||||
Text="{Binding Count}"/>
|
Text="{Binding Count}" IsVisible="{Binding HasCount}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -131,17 +133,21 @@
|
|||||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="2" Classes="list-label"
|
<TextBlock Grid.Column="2" Classes="list-label"
|
||||||
Text="{Binding Name}"
|
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
|
||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||||
<TextBlock Grid.Column="3" Classes="list-count"
|
<TextBlock Grid.Column="3" Classes="list-count"
|
||||||
Text="{Binding Count}"/>
|
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Children}"
|
<!-- Guide line drops from under the folder icon's centre (padding 10 + chevron 12 + half
|
||||||
IsVisible="{Binding !IsCollapsed}"
|
of the 20px icon slot); children indent one icon slot past it. -->
|
||||||
Margin="14,0,0,0"/>
|
<Panel IsVisible="{Binding !IsCollapsed}">
|
||||||
|
<Border Width="1" HorizontalAlignment="Left" Margin="31,0,0,4"
|
||||||
|
Background="{DynamicResource LineBrush}" IsHitTestVisible="False"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Children}" Margin="20,0,0,0"/>
|
||||||
|
</Panel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ public class ListSettingsModalViewModelTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FolderToggle_SwapsUntouchedDefaultIcon_KeepsCustomChoice()
|
||||||
|
{
|
||||||
|
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
|
||||||
|
|
||||||
|
vm.IsFolder = true;
|
||||||
|
Assert.Equal(ListSettingsModalViewModel.DefaultFolderIcon, vm.Icon);
|
||||||
|
vm.IsFolder = false;
|
||||||
|
Assert.Equal(ListSettingsModalViewModel.DefaultIcon, vm.Icon);
|
||||||
|
|
||||||
|
vm.Icon = "Star";
|
||||||
|
vm.IsFolder = true;
|
||||||
|
Assert.Equal("Star", vm.Icon);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user