diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index e47a2eb6..0f75cedc 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -260,6 +260,8 @@ "contextOpenFindings": "Findings-Ordner öffnen", "contextImportTickets": "Aus Ticketsystem aktualisieren", "contextSettings": "Einstellungen…", + "contextFolderSettings": "Ordner-Einstellungen…", + "contextRemoveFromFolder": "Aus Ordner herausnehmen", "newList": "Neue Liste", "addReposTip": "Repos als Listen hinzufügen", "noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.", @@ -403,7 +405,11 @@ }, "listSettings": { "title": "LISTENEINSTELLUNGEN", + "folderTitle": "ORDNER", "deleteList": "Liste löschen", + "deleteFolder": "Ordner löschen", + "createAsFolder": "Stattdessen einen Ordner anlegen", + "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", "name": "Name", "workingDirectory": "Arbeitsverzeichnis", @@ -793,7 +799,7 @@ "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, - "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" }, + "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene." }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 5f0398fb..504a4ffc 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -260,6 +260,8 @@ "contextOpenFindings": "Open findings folder", "contextImportTickets": "Update from ticket system", "contextSettings": "Settings…", + "contextFolderSettings": "Folder settings…", + "contextRemoveFromFolder": "Move out of folder", "newList": "New list", "addReposTip": "Add repos as lists", "noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.", @@ -403,7 +405,11 @@ }, "listSettings": { "title": "LIST SETTINGS", + "folderTitle": "FOLDER", "deleteList": "Delete list", + "deleteFolder": "Delete folder", + "createAsFolder": "Create a folder instead", + "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", "name": "Name", "workingDirectory": "Working directory", @@ -793,7 +799,7 @@ "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, - "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" }, + "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level." }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs new file mode 100644 index 00000000..38015f9f --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs @@ -0,0 +1,23 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.ViewModels.Islands; + +/// +/// A sidebar folder row. Not a and not a +/// — a folder holds no tasks and is never selectable, so tapping it +/// toggles instead of changing the selection. +/// +public sealed partial class ListFolderNavItemViewModel : ViewModelBase +{ + public required string Id { get; init; } + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _iconKey = "Folder"; + [ObservableProperty] private bool _isCollapsed; + // Sum of the child lists' counts — a collapsed folder still shows what's inside it. + [ObservableProperty] private int _count; + // Set while a dragged list row hovers over this folder header (move-into-folder target). + [ObservableProperty] private bool _isDropTarget; + + public ObservableCollection Lists { get; } = new(); +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs index d732f015..447551a8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs @@ -26,4 +26,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase [ObservableProperty] private bool _isTaskDropTarget; // Settable (not init) so a list-settings icon change refreshes the row in place. [ObservableProperty] private string? _iconKey; + // The sidebar folder this row sits in (ListEntity.FolderId); null = top level. + [ObservableProperty] private string? _folderId; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 8a7ea8b7..2a03c653 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -26,6 +26,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable /// island show the notes editor instead of rows. public const string NotesListId = "smart:notes"; + /// Icon key a folder falls back to when it has none of its own. + public const string ListFolderIcon = "Folder"; + private readonly IDbContextFactory _dbFactory; private readonly IServiceProvider? _services; private readonly IWorkerClient? _worker; @@ -202,7 +205,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public ObservableCollection Items { get; } = new(); public ObservableCollection SmartLists { get; } = new(); + /// Every user list, flat, regardless of folder — the set the count refresh, the + /// row refresh and the no-repo banner work over. public ObservableCollection UserLists { get; } = new(); + /// Sidebar folders, rendered above the top-level lists. + public ObservableCollection Folders { get; } = new(); + /// The user lists that sit outside any folder. Together with + /// this is what the sidebar binds to; stays + /// the flat superset. + public ObservableCollection RootLists { get; } = new(); [ObservableProperty] private string _searchText = ""; [ObservableProperty] private ListNavItemViewModel? _selectedList; @@ -279,6 +290,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable Items.Clear(); SmartLists.Clear(); UserLists.Clear(); + Folders.Clear(); + RootLists.Clear(); var smart = new[] { @@ -299,6 +312,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var ticketsConfigured = await IsTicketsConfiguredAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var lists = new ListRepository(ctx); + + foreach (var f in await lists.GetFoldersAsync(ct)) + Folders.Add(new ListFolderNavItemViewModel + { + Id = f.Id, + Name = f.Name, + IconKey = f.Icon ?? ListFolderIcon, + IsCollapsed = f.IsCollapsed, + }); + var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); foreach (var l in await lists.GetAllAsync(ct)) { @@ -315,9 +338,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable IsManual = l.IsManual, FindingsTracked = l.FindingsTracked, HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0, + // A folder_id pointing at a folder that isn't there (shouldn't happen — the FK + // sets it null on delete) would otherwise hide the list entirely. + FolderId = Folders.Any(f => f.Id == l.FolderId) ? l.FolderId : null, }; Items.Add(item); UserLists.Add(item); + ContainerOf(item).Add(item); } ok = true; } @@ -365,6 +392,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var filter = _filters.Resolve(item.Id); item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); } + + RecomputeFolderCounts(); ok = true; } catch (OperationCanceledException) { throw; } @@ -403,6 +432,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable }; Items.Add(item); UserLists.Add(item); + RootLists.Add(item); SelectedList = item; RecomputeHasNoLinkedRepo(); @@ -410,7 +440,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { var vm = _services.GetRequiredService(); vm.ErrorReported += msg => ErrorReported?.Invoke(msg); - vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon); + // canToggleFolder: only the create flow offers "make this a folder instead". + vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true); await Dialogs.ShowListSettingsAsync(vm); if (!vm.Saved && !vm.Deleted) { @@ -432,6 +463,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable r.DropHintAbove = false; r.DropHintBelow = false; } + foreach (var f in Folders) f.IsDropTarget = false; } public void SetDropHint(ListNavItemViewModel target, bool placeBelow) @@ -442,28 +474,133 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable r.DropHintAbove = isTarget && !placeBelow; r.DropHintBelow = isTarget && placeBelow; } + foreach (var f in Folders) f.IsDropTarget = false; } + public void SetFolderDropTarget(ListFolderNavItemViewModel? target) + { + foreach (var r in UserLists) + { + r.DropHintAbove = false; + r.DropHintBelow = false; + } + foreach (var f in Folders) f.IsDropTarget = ReferenceEquals(f, target); + } + + /// The collection a row is rendered in: its folder's Lists, or + /// when it has no folder. + public ObservableCollection ContainerOf(ListNavItemViewModel row) => + Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? RootLists; + + /// + /// Drops at 's position. Since a drop + /// position implies a container, a drag onto a row in a different folder (or onto a top-level + /// row) also *moves* the list there — that's the only way to drag a list out of a folder. + /// public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) { if (source.Kind != ListKind.User || target.Kind != ListKind.User) return; if (ReferenceEquals(source, target)) return; - MoveWithinCollection(UserLists, source, target, placeBelow); + var folderChanged = source.FolderId != target.FolderId; + if (folderChanged) + { + ContainerOf(source).Remove(source); + source.FolderId = target.FolderId; + ContainerOf(source).Add(source); + } + + MoveWithinCollection(ContainerOf(source), source, target, placeBelow); + await PersistOrderAsync(folderChanged ? source : null); + } + + /// Appends a list to a folder (or to the top level when is + /// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders. + public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder) + { + if (source.Kind != ListKind.User) return; + if (source.FolderId == folder?.Id) return; + + ContainerOf(source).Remove(source); + source.FolderId = folder?.Id; + ContainerOf(source).Add(source); + await PersistOrderAsync(source); + } + + /// + /// Writes the sidebar's visual order back as a single global sort_order sequence + /// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new + /// folder_id. + /// + private async Task PersistOrderAsync(ListNavItemViewModel? movedRow) + { + var orderedIds = Folders.SelectMany(f => f.Lists) + .Concat(RootLists) + .Select(i => RawId(i.Id)) + .ToList(); - var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList(); var sw = System.Diagnostics.Stopwatch.StartNew(); var ok = false; try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var lists = new ListRepository(ctx); + if (movedRow is not null) + await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId); await lists.ReorderAsync(orderedIds); ok = true; } finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); } + + RecomputeFolderCounts(); } + /// Context-menu counterpart to dragging a list out of a folder. + [RelayCommand] + private async Task RemoveFromFolderAsync(ListNavItemViewModel? row) + { + if (row is null) return; + await MoveToFolderAsync(row, null); + } + + [RelayCommand] + private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder) + { + if (folder is null) return; + folder.IsCollapsed = !folder.IsCollapsed; + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed); + } + catch (Exception ex) + { + // The fold itself already happened in the UI; only its persistence failed. + ErrorReported?.Invoke(ex.Message); + } + } + + [RelayCommand] + private async Task OpenFolderSettingsAsync(ListFolderNavItemViewModel? folder) + { + if (folder is null || Dialogs is null || _services is null) return; + var vm = _services.GetRequiredService(); + vm.ErrorReported += msg => ErrorReported?.Invoke(msg); + vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey); + await Dialogs.ShowListSettingsAsync(vm); + // 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. + if (vm.Saved || vm.Deleted) await LoadAsync(); + } + + private void RecomputeFolderCounts() + { + foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count); + } + + private static string RawId(string rowId) => + rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId; + private static void MoveWithinCollection( ObservableCollection coll, ListNavItemViewModel source, @@ -570,6 +707,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable IsManual = entity.IsManual, FindingsTracked = entity.FindingsTracked, HasTicketProject = hasTicketProject, + FolderId = Folders.Any(f => f.Id == entity.FolderId) ? entity.FolderId : null, }; var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); @@ -579,6 +717,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable UserLists.Insert(insertAt, item); Items.Insert(SmartLists.Count + insertAt, item); + + // Same DB-order position, but counted within the row's own container. + var container = ContainerOf(item); + var containerIdx = ordered + .Where(l => l.FolderId == item.FolderId) + .Select(l => l.Id) + .ToList() + .IndexOf(entity.Id); + container.Insert(containerIdx < 0 || containerIdx > container.Count ? container.Count : containerIdx, item); RecomputeHasNoLinkedRepo(); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 80f4c7b9..3c8e4763 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -55,7 +55,33 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Icon key into the UI icon library (IconKeyConverter -> "Icon." StreamGeometry). [ObservableProperty] private string _icon = DefaultIcon; - [ObservableProperty] private bool _ticketsAvailable; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowTicketProject))] + private bool _ticketsAvailable; + + /// The dropdown needs a configured ticket system *and* a list — folders link to nothing. + public bool ShowTicketProject => TicketsAvailable && !IsFolder; + + // ---- Folder mode ---- + // A folder is a sidebar grouping, not a list: it carries only a name and an icon, so every + // other field/section collapses. Reached two ways — the "create a folder instead" toggle in + // the create flow (FolderId still null), and editing an existing folder (FolderId set). + // There is no list <-> folder conversion: the toggle is offered on create only. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HeaderTitle))] + [NotifyPropertyChangedFor(nameof(DeleteLabel))] + [NotifyPropertyChangedFor(nameof(ShowTicketProject))] + private bool _isFolder; + + /// True only while creating — an existing list can't become a folder or back. + [ObservableProperty] private bool _canToggleFolder; + + private string? _folderId; + + // Resolved rather than a {loc:Tr} key because the text depends on IsFolder. + public string HeaderTitle => Loc.T(IsFolder ? "modals.listSettings.folderTitle" : "modals.listSettings.title"); + + public string DeleteLabel => Loc.T(IsFolder ? "modals.listSettings.deleteFolder" : "modals.listSettings.deleteList"); [ObservableProperty] private TicketProjectOption? _selectedTicketProject; public ObservableCollection TicketProjects { get; } = new(); @@ -123,8 +149,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase string defaultCommitType, bool isManual = false, bool findingsTracked = false, - string? icon = null) - => _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon); + string? icon = null, + bool canToggleFolder = false) + { + CanToggleFolder = canToggleFolder; + _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon); + } + + /// + /// 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. + /// + public void BeginLoadFolder(string folderId, string name, string? icon) + { + _folderId = folderId; + IsFolder = true; + CanToggleFolder = false; + Name = name; + Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon; + } public async Task LoadAsync( string listId, @@ -213,6 +256,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanSave))] private async Task SaveAsync() { + if (IsFolder) + { + await SaveFolderAsync(); + return; + } + await _worker.UpdateListAsync(new UpdateListDto( ListId, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, @@ -239,9 +288,45 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase CloseAction?.Invoke(); } + private async Task SaveFolderAsync() + { + var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var lists = new ListRepository(ctx); + + if (_folderId is null) + { + await lists.AddFolderAsync(new ListFolderEntity + { + Id = Guid.NewGuid().ToString("N"), + Name = name, + Icon = Icon, + CreatedAt = DateTime.UtcNow, + }); + // The create flow pre-creates a list row for this modal to save against; a folder + // doesn't need it. Deleted=true (it genuinely is) makes the caller do a full reload, + // which is what picks the new folder up. + await lists.DeleteAsync(ListId); + Deleted = true; + } + else + { + await lists.RenameFolderAsync(_folderId, name, Icon); + } + + Saved = true; + CloseAction?.Invoke(); + } + [RelayCommand(CanExecute = nameof(CanDelete))] private async Task DeleteAsync() { + if (IsFolder) + { + await DeleteFolderAsync(); + return; + } + var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; if (ConfirmAsync is not null) { @@ -270,6 +355,34 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase CloseAction?.Invoke(); } + private async Task DeleteFolderAsync() + { + // Create mode with the folder toggle on: nothing has been written yet, so this is a cancel + // — and the caller's rollback drops the pre-created list row. + if (_folderId is null) { CloseAction?.Invoke(); return; } + + var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; + if (ConfirmAsync is not null) + { + var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteFolder", displayName)); + if (!ok) return; + } + + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + await new ListRepository(ctx).DeleteFolderAsync(_folderId); + } + catch (Exception ex) + { + ErrorReported?.Invoke(ex.Message); + return; + } + + Deleted = true; + CloseAction?.Invoke(); + } + [RelayCommand] private void Cancel() => CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index bcba3dda..f4475f07 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -5,6 +5,91 @@ xmlns:loc="using:ClaudeDo.Ui.Localization" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:DataType="vm:ListsIslandViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -133,87 +218,61 @@ - + + - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index 2a886dcb..cdaef3d7 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -69,6 +69,60 @@ public partial class ListsIslandView : UserControl } } + private void OnFolderTapped(object? sender, RoutedEventArgs e) + { + if (sender is Border { DataContext: ListFolderNavItemViewModel folder } + && DataContext is ListsIslandViewModel vm) + vm.ToggleFolderCommand.Execute(folder); + } + + // Dropping onto the folder *header* appends the list to that folder — the only route into a + // collapsed or empty folder, where there's no child row to drop next to. + private void OnFolderDragOver(object? sender, DragEventArgs e) + { + if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } + if ((!e.DataTransfer?.Contains(ListRowFormat) ?? true) + || sender is not Border { DataContext: ListFolderNavItemViewModel folder }) + { + e.DragEffects = DragDropEffects.None; + vm.ClearDropHints(); + return; + } + + var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); + var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId); + if (source is null || source.FolderId == folder.Id) + { + e.DragEffects = DragDropEffects.None; + vm.ClearDropHints(); + return; + } + + vm.SetFolderDropTarget(folder); + e.DragEffects = DragDropEffects.Move; + } + + private async void OnFolderDrop(object? sender, DragEventArgs e) + { + if (DataContext is not ListsIslandViewModel vm) return; + try + { + if (sender is not Border { DataContext: ListFolderNavItemViewModel folder }) return; + + var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); + var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId); + if (source is null) return; + + vm.ClearDropHints(); + await vm.MoveToFolderAsync(source, folder); + } + catch + { + vm.ClearDropHints(); + throw; + } + } + private void OnListDragOver(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } @@ -150,11 +204,14 @@ public partial class ListsIslandView : UserControl return null; } + // Next row *within the same container* — the row below the last list of a folder belongs to + // another folder (or to the top level), so it is not a "drop above" candidate here. private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row) { - var idx = vm.UserLists.IndexOf(row); + var container = vm.ContainerOf(row); + var idx = container.IndexOf(row); if (idx < 0) return null; - return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null; + return idx + 1 < container.Count ? container[idx + 1] : null; } } diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index e75ca032..f8bdae28 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -20,10 +20,10 @@ - + -