feat(ui): collapsible folders for the list sidebar

Folders render above the top-level lists, each with its own collapsible
children; tapping a folder header folds it instead of selecting it.
Lists move in by dragging onto a folder header (or onto a row inside it)
and out by dropping between top-level rows or via the context menu.

Creating a folder is a toggle in the New-list modal, not a third sidebar
button: it reduces the modal to name + icon and writes a folder row
instead of the list row the create flow pre-created.
This commit is contained in:
mika kuns
2026-09-10 08:29:56 +02:00
parent 13e860f06b
commit 25c9a619dc
9 changed files with 522 additions and 95 deletions
+7 -1
View File
@@ -260,6 +260,8 @@
"contextOpenFindings": "Findings-Ordner öffnen", "contextOpenFindings": "Findings-Ordner öffnen",
"contextImportTickets": "Aus Ticketsystem aktualisieren", "contextImportTickets": "Aus Ticketsystem aktualisieren",
"contextSettings": "Einstellungen…", "contextSettings": "Einstellungen…",
"contextFolderSettings": "Ordner-Einstellungen…",
"contextRemoveFromFolder": "Aus Ordner herausnehmen",
"newList": "Neue Liste", "newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen", "addReposTip": "Repos als Listen hinzufügen",
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.", "noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.",
@@ -403,7 +405,11 @@
}, },
"listSettings": { "listSettings": {
"title": "LISTENEINSTELLUNGEN", "title": "LISTENEINSTELLUNGEN",
"folderTitle": "ORDNER",
"deleteList": "Liste löschen", "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", "sectionGeneral": "ALLGEMEIN",
"name": "Name", "name": "Name",
"workingDirectory": "Arbeitsverzeichnis", "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}" }, "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}" }, "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}" }, "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}" }, "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}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+7 -1
View File
@@ -260,6 +260,8 @@
"contextOpenFindings": "Open findings folder", "contextOpenFindings": "Open findings folder",
"contextImportTickets": "Update from ticket system", "contextImportTickets": "Update from ticket system",
"contextSettings": "Settings…", "contextSettings": "Settings…",
"contextFolderSettings": "Folder settings…",
"contextRemoveFromFolder": "Move out of folder",
"newList": "New list", "newList": "New list",
"addReposTip": "Add repos as lists", "addReposTip": "Add repos as lists",
"noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.", "noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.",
@@ -403,7 +405,11 @@
}, },
"listSettings": { "listSettings": {
"title": "LIST SETTINGS", "title": "LIST SETTINGS",
"folderTitle": "FOLDER",
"deleteList": "Delete list", "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", "sectionGeneral": "GENERAL",
"name": "Name", "name": "Name",
"workingDirectory": "Working directory", "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}" }, "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}" }, "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}" }, "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}" }, "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}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
@@ -0,0 +1,23 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
/// </summary>
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<ListNavItemViewModel> Lists { get; } = new();
}
@@ -26,4 +26,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isTaskDropTarget; [ObservableProperty] private bool _isTaskDropTarget;
// Settable (not init) so a list-settings icon change refreshes the row in place. // Settable (not init) so a list-settings icon change refreshes the row in place.
[ObservableProperty] private string? _iconKey; [ObservableProperty] private string? _iconKey;
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
[ObservableProperty] private string? _folderId;
} }
@@ -26,6 +26,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// island show the notes editor instead of rows.</summary> /// island show the notes editor instead of rows.</summary>
public const string NotesListId = "smart:notes"; public const string NotesListId = "smart:notes";
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
public const string ListFolderIcon = "Folder";
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;
@@ -202,7 +205,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public ObservableCollection<ListNavItemViewModel> Items { get; } = new(); public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
/// row refresh and the no-repo banner work over.</summary>
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
/// <summary>Sidebar folders, rendered above the top-level lists.</summary>
public ObservableCollection<ListFolderNavItemViewModel> Folders { get; } = new();
/// <summary>The user lists that sit outside any folder. Together with
/// <see cref="Folders"/> this is what the sidebar binds to; <see cref="UserLists"/> stays
/// the flat superset.</summary>
public ObservableCollection<ListNavItemViewModel> RootLists { get; } = new();
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private ListNavItemViewModel? _selectedList; [ObservableProperty] private ListNavItemViewModel? _selectedList;
@@ -279,6 +290,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Items.Clear(); Items.Clear();
SmartLists.Clear(); SmartLists.Clear();
UserLists.Clear(); UserLists.Clear();
Folders.Clear();
RootLists.Clear();
var smart = new[] var smart = new[]
{ {
@@ -299,6 +312,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var ticketsConfigured = await IsTicketsConfiguredAsync(); var ticketsConfigured = await IsTicketsConfiguredAsync();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); 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<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
foreach (var l in await lists.GetAllAsync(ct)) foreach (var l in await lists.GetAllAsync(ct))
{ {
@@ -315,9 +338,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = l.IsManual, IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked, FindingsTracked = l.FindingsTracked,
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0, 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); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
ContainerOf(item).Add(item);
} }
ok = true; ok = true;
} }
@@ -365,6 +392,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var filter = _filters.Resolve(item.Id); var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
} }
RecomputeFolderCounts();
ok = true; ok = true;
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
@@ -403,6 +432,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
RootLists.Add(item);
SelectedList = item; SelectedList = item;
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
@@ -410,7 +440,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg); 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); await Dialogs.ShowListSettingsAsync(vm);
if (!vm.Saved && !vm.Deleted) if (!vm.Saved && !vm.Deleted)
{ {
@@ -432,6 +463,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = false; r.DropHintAbove = false;
r.DropHintBelow = false; r.DropHintBelow = false;
} }
foreach (var f in Folders) f.IsDropTarget = false;
} }
public void SetDropHint(ListNavItemViewModel target, bool placeBelow) public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
@@ -442,28 +474,133 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = isTarget && !placeBelow; r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = 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);
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <see cref="RootLists"/> when it has no folder.</summary>
public ObservableCollection<ListNavItemViewModel> ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? RootLists;
/// <summary>
/// Drops <paramref name="source"/> at <paramref name="target"/>'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.
/// </summary>
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
{ {
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return; if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) 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);
}
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
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);
}
/// <summary>
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
/// <c>folder_id</c>.
/// </summary>
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 sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false; var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
if (movedRow is not null)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
await lists.ReorderAsync(orderedIds); await lists.ReorderAsync(orderedIds);
ok = true; ok = true;
} }
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
RecomputeFolderCounts();
} }
/// <summary>Context-menu counterpart to dragging a list out of a folder.</summary>
[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<ListSettingsModalViewModel>();
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( private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll, ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source, ListNavItemViewModel source,
@@ -570,6 +707,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = entity.IsManual, IsManual = entity.IsManual,
FindingsTracked = entity.FindingsTracked, FindingsTracked = entity.FindingsTracked,
HasTicketProject = hasTicketProject, HasTicketProject = hasTicketProject,
FolderId = Folders.Any(f => f.Id == entity.FolderId) ? entity.FolderId : null,
}; };
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -579,6 +717,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
UserLists.Insert(insertAt, item); UserLists.Insert(insertAt, item);
Items.Insert(SmartLists.Count + 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(); RecomputeHasNoLinkedRepo();
} }
} }
@@ -55,7 +55,33 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry). // Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon; [ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty] private bool _ticketsAvailable; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
private bool _ticketsAvailable;
/// <summary>The dropdown needs a configured ticket system *and* a list — folders link to nothing.</summary>
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;
/// <summary>True only while creating — an existing list can't become a folder or back.</summary>
[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; [ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new(); public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
@@ -123,8 +149,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string defaultCommitType, string defaultCommitType,
bool isManual = false, bool isManual = false,
bool findingsTracked = false, bool findingsTracked = false,
string? icon = null) string? icon = null,
=> _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon); bool canToggleFolder = false)
{
CanToggleFolder = canToggleFolder;
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
}
/// <summary>
/// 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.
/// </summary>
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( public async Task LoadAsync(
string listId, string listId,
@@ -213,6 +256,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (IsFolder)
{
await SaveFolderAsync();
return;
}
await _worker.UpdateListAsync(new UpdateListDto( await _worker.UpdateListAsync(new UpdateListDto(
ListId, ListId,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
@@ -239,9 +288,45 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke(); 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))] [RelayCommand(CanExecute = nameof(CanDelete))]
private async Task DeleteAsync() private async Task DeleteAsync()
{ {
if (IsFolder)
{
await DeleteFolderAsync();
return;
}
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null) if (ConfirmAsync is not null)
{ {
@@ -270,6 +355,34 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke(); 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] [RelayCommand]
private void Cancel() => CloseAction?.Invoke(); private void Cancel() => CloseAction?.Invoke();
} }
@@ -5,6 +5,91 @@
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel"> x:DataType="vm:ListsIslandViewModel">
<UserControl.Resources>
<!-- One user-list row. Shared by the top-level list and by each folder's children, so the
row (context menu, drag handlers, drop indicators) exists once. -->
<DataTemplate x:Key="UserListRow" DataType="vm:ListNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextRemoveFromFolder}"
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).RemoveFromFolderCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</UserControl.Resources>
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- ── Header ── --> <!-- ── Header ── -->
@@ -133,87 +218,61 @@
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/> <PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button> </Button>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding UserLists}"> <!-- Folders first, each with its own (collapsible) child lists, then the top-level lists.
Folders are never selectable — tapping the header folds it. -->
<ItemsControl ItemsSource="{Binding Folders}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel"> <DataTemplate DataType="vm:ListFolderNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto"> <StackPanel>
<Border Classes="list-item"
<!-- Above-row drop indicator --> Classes.drop-target="{Binding IsDropTarget}"
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0" Tapped="OnFolderTapped"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True" DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver" DragDrop.DragOver="OnFolderDragOver"
DragDrop.Drop="OnListDrop"> DragDrop.Drop="OnFolderDrop">
<Border.ContextMenu> <Border.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}" <MenuItem Header="{loc:Tr lists.contextFolderSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto"> <Grid ColumnDefinitions="12,20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon" <PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<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 --> <TextBlock Grid.Column="2" Classes="list-label"
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}" Text="{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 --> <TextBlock Grid.Column="3" Classes="list-count"
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/> Text="{Binding Count}"/>
</Grid> </Grid>
</Border> </Border>
<!-- Below-row drop indicator (last item only) --> <ItemsControl ItemsSource="{Binding Lists}"
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0" IsVisible="{Binding !IsCollapsed}"
Background="{DynamicResource MossBrush}" CornerRadius="1" Margin="14,0,0,0"
IsVisible="{Binding DropHintBelow}"/> ItemTemplate="{StaticResource UserListRow}"/>
</Grid> </StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<ItemsControl ItemsSource="{Binding RootLists}"
ItemTemplate="{StaticResource UserListRow}"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
@@ -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) private void OnListDragOver(object? sender, DragEventArgs e)
{ {
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
@@ -150,11 +204,14 @@ public partial class ListsIslandView : UserControl
return null; 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) 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; 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;
} }
} }
@@ -20,10 +20,10 @@
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/> <KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.listSettings.title}" CloseCommand="{Binding CancelCommand}"> <ctl:ModalShell Title="{Binding HeaderTitle}" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<Button Grid.Column="0" Content="{loc:Tr modals.listSettings.deleteList}" Classes="danger" <Button Grid.Column="0" Content="{Binding DeleteLabel}" Classes="danger"
Command="{Binding DeleteCommand}" MinWidth="90"/> Command="{Binding DeleteCommand}" MinWidth="90"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/> <Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
@@ -40,8 +40,12 @@
VerticalAlignment="Top"> VerticalAlignment="Top">
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/> <!-- A folder runs nothing, so it has neither agent config nor a verify command. The
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/> TabItems stay in place (index-driven host) and just become unreachable. -->
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"
IsVisible="{Binding !IsFolder}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"
IsVisible="{Binding !IsFolder}"/>
</ListBox> </ListBox>
<Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/> <Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/>
@@ -69,12 +73,22 @@
<TabItem Header="{loc:Tr modals.listSettings.sectionGeneral}"> <TabItem Header="{loc:Tr modals.listSettings.sectionGeneral}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<!-- Create flow only: turns this into a sidebar folder (name + icon, nothing else).
Deliberately not a second sidebar button — the "+" and the repo-import button
already sit there. -->
<StackPanel Spacing="4" IsVisible="{Binding CanToggleFolder}">
<CheckBox IsChecked="{Binding IsFolder, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.createAsFolder}"/>
<TextBlock Text="{loc:Tr modals.listSettings.createAsFolderHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.name}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.name}"/>
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <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">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" /> <TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" />
@@ -82,35 +96,35 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/>
<ComboBox ItemsSource="{Binding CommitTypeOptions}" <ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}" SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" /> HorizontalAlignment="Left" MinWidth="160" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}" <CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.manualList}"/> Content="{loc:Tr modals.listSettings.manualList}"/>
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}" <TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}" <CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.findingsTracked}"/> Content="{loc:Tr modals.listSettings.findingsTracked}"/>
<TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}" <TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}" <CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/> Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}" <TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}"> <StackPanel Spacing="4" IsVisible="{Binding ShowTicketProject}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}" <ComboBox ItemsSource="{Binding TicketProjects}"
SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}" SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}"