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 @@
-
+
-
@@ -40,8 +40,12 @@
VerticalAlignment="Top">
-
-
+
+
+
@@ -69,12 +73,22 @@
+
+
+
+
+
+
-
+
@@ -82,35 +96,35 @@
-
+
-
+
-
+
-
+
-
+