feat(lists): assign a colour to a sidebar folder

Tints the folder name and its guide line; picked from a swatch row in the folder settings.
This commit is contained in:
mika kuns
2026-09-30 10:12:41 +02:00
parent 8a31a67c74
commit bb92dc279f
15 changed files with 1257 additions and 9 deletions
@@ -15,6 +15,7 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
builder.Property(f => f.Name).HasColumnName("name").IsRequired(); builder.Property(f => f.Name).HasColumnName("name").IsRequired();
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(f => f.Icon).HasColumnName("icon"); builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.Color).HasColumnName("color");
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false); builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id"); builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListFolderColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "color",
table: "list_folders",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "color",
table: "list_folders");
}
}
}
@@ -370,6 +370,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("Color")
.HasColumnType("TEXT")
.HasColumnName("color");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -13,6 +13,8 @@ public sealed class ListFolderEntity
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon. // Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
public string? Icon { get; set; } public string? Icon { get; set; }
/// <summary>Hex colour (#RRGGBB) for the sidebar name and guide line; null = theme default.</summary>
public string? Color { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is // Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
// already being read for the folder itself. // already being read for the folder itself.
@@ -109,16 +109,17 @@ public sealed class ListRepository
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
/// <summary>Patches just the two editable fields — a whole-entity Update would need /// <summary>Patches just the editable fields — a whole-entity Update would need
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset /// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary> /// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default) public async Task RenameFolderAsync(string folderId, string name, string? icon, string? color, CancellationToken ct = default)
{ {
await _context.ListFolders await _context.ListFolders
.Where(f => f.Id == folderId) .Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(f => f.Name, name) .SetProperty(f => f.Name, name)
.SetProperty(f => f.Icon, icon), ct); .SetProperty(f => f.Icon, icon)
.SetProperty(f => f.Color, color), ct);
} }
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via /// <summary>Deletes the folder only — its lists survive and fall back to the top level via
@@ -460,6 +460,8 @@
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.", "createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
"sectionGeneral": "ALLGEMEIN", "sectionGeneral": "ALLGEMEIN",
"name": "Name", "name": "Name",
"folderColor": "Farbe",
"folderColorHint": "Färbt den Ordnernamen und seine Führungslinie in der Seitenleiste.",
"workingDirectory": "Arbeitsverzeichnis", "workingDirectory": "Arbeitsverzeichnis",
"workingDirectoryPlaceholder": "(keines)", "workingDirectoryPlaceholder": "(keines)",
"browse": "Durchsuchen...", "browse": "Durchsuchen...",
@@ -460,6 +460,8 @@
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.", "createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
"sectionGeneral": "GENERAL", "sectionGeneral": "GENERAL",
"name": "Name", "name": "Name",
"folderColor": "Colour",
"folderColorHint": "Tints the folder name and its guide line in the sidebar.",
"workingDirectory": "Working directory", "workingDirectory": "Working directory",
"workingDirectoryPlaceholder": "(none)", "workingDirectoryPlaceholder": "(none)",
"browse": "Browse...", "browse": "Browse...",
@@ -9,6 +9,16 @@ namespace ClaudeDo.Ui.Converters;
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the // instantiate a generic FuncValueConverter as a resource; they stay one per type so the
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file. // x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
/// <summary>"#RRGGBB" to a brush; anything else falls back to the app resource named by the parameter.</summary>
public sealed class HexBrushConverter : OneWayConverter
{
public static HexBrushConverter Instance { get; } = new();
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string s && Color.TryParse(s, out var c)
? new SolidColorBrush(c)
: Avalonia.Application.Current?.TryGetResource(parameter as string ?? "", null, out var res) == true ? res : null;
}
public sealed class UpperCaseConverter : OneWayConverter public sealed class UpperCaseConverter : OneWayConverter
{ {
public static UpperCaseConverter Instance { get; } = new(); public static UpperCaseConverter Instance { get; } = new();
@@ -16,6 +16,8 @@ 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";
/// <summary>Hex colour for name + guide line; null = theme default.</summary>
[ObservableProperty] private string? _color;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCount))] [NotifyPropertyChangedFor(nameof(ShowCount))]
private bool _isCollapsed; private bool _isCollapsed;
@@ -352,6 +352,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = f.Id, Id = f.Id,
Name = f.Name, Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon, IconKey = f.Icon ?? ListFolderIcon,
Color = f.Color,
IsCollapsed = f.IsCollapsed, IsCollapsed = f.IsCollapsed,
ParentFolderId = f.ParentFolderId, ParentFolderId = f.ParentFolderId,
}; };
@@ -758,7 +759,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (folder is null || Dialogs is null || _services is null) return; if (folder is null || Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg); vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey); vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey, folder.Color);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
// Rename, re-icon and delete all change the folder header (and delete releases its lists // Rename, re-icon and delete all change the folder header (and delete releases its lists
// back to the top level) — one reload covers every outcome. // back to the top level) — one reload covers every outcome.
@@ -115,6 +115,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public const string DefaultIcon = "Dot"; public const string DefaultIcon = "Dot";
/// <summary>Folder colour swatches; "" = no colour (theme default).</summary>
public static readonly string[] FolderColorOptions =
["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"];
/// <summary>Selected folder colour, "" for none.</summary>
[ObservableProperty] private string _folderColor = "";
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary> /// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
public const string DefaultFolderIcon = "Folder"; public const string DefaultFolderIcon = "Folder";
@@ -229,8 +236,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
/// Opens the modal on an existing folder: name + icon only, no list config to load and /// Opens the modal on an existing folder: name + icon only, no list config to load and
/// nothing to fetch from the worker, so this needs no async counterpart. /// nothing to fetch from the worker, so this needs no async counterpart.
/// </summary> /// </summary>
public void BeginLoadFolder(string folderId, string name, string? icon) public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
{ {
FolderColor = color ?? "";
_folderId = folderId; _folderId = folderId;
IsFolder = true; IsFolder = true;
CanToggleFolder = false; CanToggleFolder = false;
@@ -375,6 +383,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
private async Task SaveFolderAsync() private async Task SaveFolderAsync()
{ {
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
@@ -388,6 +398,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
Name = name, Name = name,
Icon = Icon, Icon = Icon,
Color = NullIfEmpty(FolderColor),
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}); });
// The create flow pre-creates a list row for this modal to save against; a folder // The create flow pre-creates a list row for this modal to save against; a folder
@@ -398,7 +409,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
} }
else else
{ {
await lists.RenameFolderAsync(_folderId, name, Icon); await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor));
} }
Saved = true; Saved = true;
@@ -153,7 +153,8 @@
<TextBlock Grid.Column="2" Classes="list-label" <TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}" ToolTip.Tip="{Binding Name}" Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold" VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/> Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count" <TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/> Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
</Grid> </Grid>
@@ -163,7 +164,8 @@
glyph); children start past it so their highlight never touches the line. --> glyph); children start past it so their highlight never touches the line. -->
<Panel IsVisible="{Binding !IsCollapsed}"> <Panel IsVisible="{Binding !IsCollapsed}">
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4" <Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
Background="{DynamicResource LineBrush}" IsHitTestVisible="False"/> Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
IsHitTestVisible="False"/>
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/> <ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
</Panel> </Panel>
</StackPanel> </StackPanel>
@@ -3,6 +3,7 @@
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView" x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
x:DataType="vm:ListSettingsModalViewModel" x:DataType="vm:ListSettingsModalViewModel"
Title="{loc:Tr modals.listSettings.title}" Title="{loc:Tr modals.listSettings.title}"
@@ -88,6 +89,30 @@
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.folderColor}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.FolderColorOptions}"
SelectedItem="{Binding FolderColor, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- "" renders as an empty ring: the "no colour" swatch. -->
<Border Width="18" Height="18" Margin="3" CornerRadius="9"
BorderThickness="1" BorderBrush="{DynamicResource LineBrightBrush}"
Background="{Binding Converter={x:Static conv:HexBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.folderColorHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
+2 -1
View File
@@ -67,13 +67,14 @@ public sealed class ListFolderTests : IDisposable
} }
await using (var ctx = Open()) await using (var ctx = Open())
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star"); await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star", "#7FA7C9");
await using (var ctx = Open()) await using (var ctx = Open())
{ {
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2"); var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
Assert.Equal("Renamed", folder.Name); Assert.Equal("Renamed", folder.Name);
Assert.Equal("Star", folder.Icon); Assert.Equal("Star", folder.Icon);
Assert.Equal("#7FA7C9", folder.Color);
Assert.True(folder.IsCollapsed); Assert.True(folder.IsCollapsed);
Assert.Equal(1, folder.SortOrder); Assert.Equal(1, folder.SortOrder);
} }