diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 31281061..c1f3e25d 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -473,6 +473,8 @@ "sectionIcon": "ICON", "icon": "Listen-Icon", "iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.", + "iconSearchWatermark": "Alle Icons durchsuchen (Englisch, z.B. rocket, car, bug)", + "iconSearchTruncated": "Die ersten 200 Treffer werden angezeigt — Suche verfeinern.", "sectionAgent": "AGENT", "resetAgentSettings": "Agent-Einstellungen zurücksetzen", "sectionVerify": "VERIFIKATION", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index fa7467cb..461b1b8b 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -473,6 +473,8 @@ "sectionIcon": "ICON", "icon": "List icon", "iconHint": "Shown next to the list name in the sidebar.", + "iconSearchWatermark": "Search all icons (English, e.g. rocket, car, bug)", + "iconSearchTruncated": "Showing the first 200 matches — refine the search.", "sectionAgent": "AGENT", "resetAgentSettings": "Reset agent settings", "sectionVerify": "VERIFICATION", diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 9de87d15..a0e5a141 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -10,6 +10,7 @@ + diff --git a/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs b/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs index 39a7c6da..8d0d4e34 100644 --- a/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs +++ b/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs @@ -2,27 +2,49 @@ using System.Globalization; using Avalonia; using Avalonia.Data.Converters; using Avalonia.Media; +using Material.Icons; namespace ClaudeDo.Ui.Converters; /// -/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun". +/// Converts an icon key to its geometry: "Sun" → the "Icon.Sun" StreamGeometry resource, +/// "mdi:RocketLaunch" → the Material Design Icons path of that . /// public class IconKeyConverter : IValueConverter { + /// Key prefix marking a Material Design Icons kind. + public const string MdiPrefix = "mdi:"; + public static IconKeyConverter Instance { get; } = new(); + private static readonly Dictionary MdiCache = new(); + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value is not string key || string.IsNullOrEmpty(key)) return null; + if (key.StartsWith(MdiPrefix, StringComparison.Ordinal)) + return MdiGeometry(key[MdiPrefix.Length..]); + var resourceKey = $"Icon.{key}"; if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true) return res as StreamGeometry; return null; } + /// Parses (and caches) the geometry for an MDI kind name; null for unknown names. + private static Geometry? MdiGeometry(string name) + { + if (MdiCache.TryGetValue(name, out var cached)) + return cached; + var geometry = Enum.TryParse(name, out var kind) + ? StreamGeometry.Parse(MaterialIconDataProvider.GetData(kind)) + : null; + MdiCache[name] = geometry; + return geometry; + } + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index aa409720..ce86f024 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -2,12 +2,14 @@ using System.Collections.ObjectModel; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Converters; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Modals.Settings; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Material.Icons; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels.Modals; @@ -132,7 +134,66 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase "Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull", ]; - public ObservableCollection Icons { get; } = new(IconOptions); + /// Cap on search hits so the picker's WrapPanel stays responsive. + public const int MaxIconResults = 200; + + // One entry per distinct MDI icon; the enum carries aliases, all of which stay searchable. + private static readonly Lazy<(string Key, string Names)[]> MdiIndex = new(() => + Enum.GetNames() + .GroupBy(Enum.Parse) + .Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g))) + .ToArray()); + + /// Picker items: the curated set, or MDI search hits once the search has 2+ characters. + [ObservableProperty] private IReadOnlyList _icons = IconOptions; + + /// Free-text filter over the MDI icon names (English). + [ObservableProperty] private string _iconSearch = ""; + + /// True when the search hit more than icons. + [ObservableProperty] private bool _iconResultsTruncated; + + // Separate from Icon: swapping Icons makes the ListBox push null here, which must not wipe the choice. + [ObservableProperty] private string? _selectedIconOption = DefaultIcon; + + /// True for a curated key or an existing "mdi:" kind. + public static bool IsKnownIcon(string? key) => + key is not null && (IconOptions.Contains(key) + || (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal) + && Enum.TryParse(key[IconKeyConverter.MdiPrefix.Length..], out _))); + + partial void OnIconSearchChanged(string value) + { + var q = value.Trim(); + if (q.Length < 2) + { + IconResultsTruncated = false; + // Keep a chosen MDI icon visible (and selected) in the curated view. + Icons = IconOptions.Contains(Icon) ? IconOptions : [Icon, .. IconOptions]; + } + else + { + var hits = MdiIndex.Value + .Where(e => e.Names.Contains(q, StringComparison.OrdinalIgnoreCase)) + .Select(e => e.Key) + .Take(MaxIconResults + 1) + .ToList(); + IconResultsTruncated = hits.Count > MaxIconResults; + Icons = hits.Take(MaxIconResults).ToList(); + } + SelectedIconOption = Icons.Contains(Icon) ? Icon : null; + } + + partial void OnSelectedIconOptionChanged(string? value) + { + if (value is not null) Icon = value; + } + + partial void OnIconChanged(string value) + { + if (!Icons.Contains(value)) OnIconSearchChanged(IconSearch); + else SelectedIconOption = value; + } // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), // scoped to this list (list → global inheritance). @@ -174,7 +235,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase IsFolder = true; CanToggleFolder = false; Name = name; - Icon = IconOptions.Contains(icon) ? icon! : DefaultFolderIcon; + Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon; } public async Task LoadAsync( @@ -198,7 +259,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase FindingsTracked = findingsTracked; // An unknown/legacy key would leave the picker with no selection and silently reset the // icon on the next save — fall back to the default instead. - Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon; + Icon = IsKnownIcon(icon) ? icon! : DefaultIcon; WorkingDir = workingDir ?? ""; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index 365111e5..a739efa8 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -143,8 +143,13 @@ + + @@ -157,6 +162,7 @@ diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs index 404399fd..2bbf2995 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs @@ -74,6 +74,27 @@ public class ListSettingsModalViewModelTests Assert.Equal("Star", vm.Icon); } + [Fact] + public void IconSearch_FindsMdiIcons_AndClearingKeepsTheChoice() + { + var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory()); + + vm.IconSearch = "rocket"; + Assert.Contains("mdi:RocketLaunch", vm.Icons); + Assert.DoesNotContain("Star", vm.Icons); + + vm.SelectedIconOption = "mdi:RocketLaunch"; + vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped + Assert.Equal("mdi:RocketLaunch", vm.Icon); + + vm.IconSearch = ""; + Assert.Equal("mdi:RocketLaunch", vm.Icons[0]); + Assert.Equal("mdi:RocketLaunch", vm.SelectedIconOption); + + Assert.True(ListSettingsModalViewModel.IsKnownIcon("mdi:RocketLaunch")); + Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon")); + } + [Fact] public async Task LoadAsync_CallsGetListConfig_ExactlyOnce() {