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
@@ -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.<key>" StreamGeometry).
[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;
public ObservableCollection<TicketProjectOption> 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);
}
/// <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(
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();
}