diff --git a/InterlinedList/Models/ListFolder.cs b/InterlinedList/Models/ListFolder.cs
new file mode 100644
index 0000000..0892ead
--- /dev/null
+++ b/InterlinedList/Models/ListFolder.cs
@@ -0,0 +1,28 @@
+namespace InterlinedList.Models;
+
+///
+/// A list folder, from GET /api/folders.
+///
+///
+///
+/// A separate resource from document folders (/api/documents/folders)
+/// despite the similar shape — different endpoints, different ids, no overlap.
+///
+///
+/// Live shape verified 2026-09-16 by creating a parent and a child on a
+/// throwaway (both since deleted): the payload is exactly
+/// {id, name, parentId}, flat, with nesting expressed only through
+/// parentId. There is no createdAt/updatedAt/userId
+/// and no server-side nesting.
+///
+///
+public sealed class ListFolder
+{
+ public required string Id { get; init; }
+ public required string Name { get; init; }
+
+ /// Parent folder, or null at root. The only nesting signal.
+ public string? ParentId { get; init; }
+
+ public bool IsRoot => ParentId is null or "";
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.ListFolders.cs b/InterlinedList/Services/InterlinedApiClient.ListFolders.cs
new file mode 100644
index 0000000..8892cca
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.ListFolders.cs
@@ -0,0 +1,141 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// List folders — /api/folders.
+///
+///
+///
+/// Distinct from document folders (/api/documents/folders), which the app
+/// already implements: different endpoints, different ids, no overlap. This
+/// domain was entirely missing.
+///
+///
+/// All four operations verified live 2026-09-16 against throwaway folders
+/// (since deleted, account confirmed restored). Envelopes:
+///
+///
+/// GET /api/folders -> 200 {"folders":[{id,name,parentId}, …]} // flat
+/// POST /api/folders -> 201 {"message":"Folder created successfully","folder":{id,name,parentId}}
+/// PUT /api/folders/{id} -> 200 {"message":"Folder updated successfully","folder":{…}}
+/// DELETE /api/folders/{id} -> 200
+///
+///
+/// ⚠️ The server does NOT guard against cycles. Moving a folder into its
+/// own descendant returns 200 and creates a loop — verified by moving a
+/// parent into its own child. A tree renderer walking parentId would
+/// then recurse forever. is therefore not
+/// defensive politeness; it is the only thing preventing it.
+///
+///
+public sealed partial class InterlinedApiClient
+{
+ public async Task> GetListFoldersAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/folders", ct);
+ return json.TryGetProperty("folders", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? []
+ : [];
+ }
+
+ /// Create a folder. Subscribers only — a free account gets 402/403.
+ public async Task CreateListFolderAsync(
+ string name, string? parentId = null, CancellationToken ct = default)
+ {
+ var body = new Dictionary { ["name"] = name };
+ // Only send parentId when there is one. An explicit null is a 500 on
+ // some writers on this API (see #171), so don't risk it.
+ if (parentId is { Length: > 0 }) body["parentId"] = parentId;
+
+ var json = await SendJsonAsync(HttpMethod.Post, "api/folders", body, ct);
+ return ReadFolder(json);
+ }
+
+ ///
+ /// Rename and/or move a folder. Pass only what is changing.
+ ///
+ ///
+ /// New parent. Pass to move to the top level —
+ /// an explicit JSON null is what the server wants there, and it was
+ /// verified to work.
+ ///
+ public async Task UpdateListFolderAsync(
+ string id, string? name = null, string? parentId = null,
+ bool moveToRoot = false, CancellationToken ct = default)
+ {
+ var body = new Dictionary();
+ if (name is { Length: > 0 }) body["name"] = name;
+ if (moveToRoot) body["parentId"] = null;
+ else if (parentId is { Length: > 0 }) body["parentId"] = parentId;
+
+ var json = await SendJsonAsync(HttpMethod.Put, $"api/folders/{id}", body, ct);
+ return ReadFolder(json);
+ }
+
+ /// Sentinel for UpdateListFolderAsync(moveToRoot: true) readability.
+ public const bool MoveToRoot = true;
+
+ ///
+ /// Soft-delete a folder and its subfolders. Confirm before calling —
+ /// the cascade is not obvious from the name.
+ ///
+ public Task DeleteListFolderAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/folders/{id}", null, ct);
+
+ ///
+ /// Assign a list to a folder, or clear it with
+ /// null.
+ ///
+ ///
+ /// folderId is not among the eleven fields
+ /// POST /api/lists accepts, so a new list cannot be created straight
+ /// into a folder — it has to be moved afterward. Verified live:
+ /// PUT /api/lists/{id} with {"folderId": …} returns
+ /// 200 and the list's folderId reflects it, and
+ /// {"folderId": null} clears it. See #179.
+ ///
+ public Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}",
+ new Dictionary { ["folderId"] = folderId }, ct);
+
+ private static ListFolder? ReadFolder(JsonElement json)
+ {
+ if (json.ValueKind != JsonValueKind.Object) return null;
+ // Create/update wrap the object under "folder"; tolerate a bare object.
+ var node = json.TryGetProperty("folder", out var wrapped) ? wrapped : json;
+ return node.ValueKind == JsonValueKind.Object
+ ? node.Deserialize(JsonOptions)
+ : null;
+ }
+
+ ///
+ /// True when moving under
+ /// would create a loop.
+ ///
+ ///
+ /// The server permits this — verified live, moving a parent into its
+ /// own child returned 200 and produced a genuine cycle. Nothing
+ /// server-side prevents it, so every move must be checked here first or a
+ /// tree walk will hang.
+ ///
+ public static bool WouldCreateCycle(
+ IReadOnlyCollection folders, string folderId, string? newParentId)
+ {
+ if (newParentId is null or "") return false; // moving to root is always safe
+ if (newParentId == folderId) return true; // its own parent
+
+ var byId = folders.ToDictionary(f => f.Id, StringComparer.Ordinal);
+ var cursor = newParentId;
+ // Bounded by the folder count, so a pre-existing cycle in the data
+ // can't hang this check either.
+ for (var hops = 0; hops <= folders.Count && cursor is { Length: > 0 }; hops++)
+ {
+ if (cursor == folderId) return true;
+ cursor = byId.TryGetValue(cursor, out var parent) ? parent.ParentId : null;
+ }
+ return false;
+ }
+}
diff --git a/InterlinedList/ViewModels/ListFolderPanelViewModel.cs b/InterlinedList/ViewModels/ListFolderPanelViewModel.cs
new file mode 100644
index 0000000..d3c90e0
--- /dev/null
+++ b/InterlinedList/ViewModels/ListFolderPanelViewModel.cs
@@ -0,0 +1,255 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The list-folder tree: browse, create, rename, move, delete.
+///
+///
+/// The API returns a flat array and expresses nesting only through
+/// parentId, so the tree is built here. Critically, the server does
+/// not reject a cycle — moving a folder into its own descendant returns
+/// 200 — so every move is checked with
+/// first, and the tree build
+/// is itself cycle-tolerant in case the data already contains one.
+///
+public partial class ListFolderPanelViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+ private List _flat = [];
+
+ public ObservableCollection Roots { get; } = new();
+
+ /// Every folder, flat — for a "move to…" picker.
+ public ObservableCollection AllFolders { get; } = new();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CreateFolderCommand))]
+ private string newFolderName = "";
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CreateFolderCommand))]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ [ObservableProperty]
+ private string? statusMessage;
+
+ /// Set when the data itself contains a loop, so the UI can say so.
+ [ObservableProperty]
+ private bool hasOrphanedFolders;
+
+ public ListFolderPanelViewModel(SessionService session) => _session = session;
+
+ [RelayCommand]
+ private async Task LoadAsync()
+ {
+ IsBusy = true;
+ ErrorMessage = null;
+ try
+ {
+ _flat = await _session.Api.GetListFoldersAsync();
+ Rebuild();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private void Rebuild()
+ {
+ Roots.Clear();
+ AllFolders.Clear();
+ foreach (var f in _flat.OrderBy(f => f.Name, StringComparer.CurrentCultureIgnoreCase))
+ AllFolders.Add(f);
+
+ var byParent = _flat
+ .GroupBy(f => f.ParentId ?? "")
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
+
+ // Track what we've placed. Because the server allows cycles, a naive
+ // recursive build can loop forever — so nodes are only ever visited
+ // once and anything left over is reported rather than rendered wrongly.
+ var placed = new HashSet(StringComparer.Ordinal);
+
+ foreach (var root in Children(byParent, "", placed))
+ Roots.Add(root);
+
+ HasOrphanedFolders = placed.Count < _flat.Count;
+ }
+
+ private List Children(
+ Dictionary> byParent, string parentKey, HashSet placed)
+ {
+ if (!byParent.TryGetValue(parentKey, out var kids)) return [];
+
+ var result = new List();
+ foreach (var kid in kids.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase))
+ {
+ // Cycle guard: never place the same folder twice.
+ if (!placed.Add(kid.Id)) continue;
+ result.Add(new ListFolderNodeViewModel(kid, Children(byParent, kid.Id, placed), this));
+ }
+ return result;
+ }
+
+ private bool CanCreate() => !IsBusy && !string.IsNullOrWhiteSpace(NewFolderName);
+
+ [RelayCommand(CanExecute = nameof(CanCreate))]
+ private async Task CreateFolderAsync()
+ {
+ IsBusy = true;
+ ErrorMessage = null;
+ StatusMessage = null;
+ try
+ {
+ await _session.Api.CreateListFolderAsync(NewFolderName.Trim());
+ NewFolderName = "";
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ // Creating a folder is subscriber-gated; say that rather than
+ // showing a bare status code.
+ ErrorMessage = ex.StatusCode is 402 or 403
+ ? "Creating list folders is a Subscriber feature."
+ : ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ internal async Task RenameAsync(ListFolderNodeViewModel node, string newName)
+ {
+ if (string.IsNullOrWhiteSpace(newName)) return;
+ try
+ {
+ await _session.Api.UpdateListFolderAsync(node.Id, name: newName.Trim());
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex) { ErrorMessage = ex.Message; }
+ }
+
+ internal async Task MoveAsync(ListFolderNodeViewModel node, string? newParentId)
+ {
+ // THE check. The server returns 200 for a cycle, so this is the only
+ // thing standing between a move and an unrenderable tree.
+ if (InterlinedApiClient.WouldCreateCycle(_flat, node.Id, newParentId))
+ {
+ ErrorMessage = $"Can't move “{node.Name}” inside itself or one of its own subfolders.";
+ return;
+ }
+
+ try
+ {
+ if (newParentId is null or "")
+ await _session.Api.UpdateListFolderAsync(node.Id, moveToRoot: true);
+ else
+ await _session.Api.UpdateListFolderAsync(node.Id, parentId: newParentId);
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex) { ErrorMessage = ex.Message; }
+ }
+
+ internal async Task DeleteAsync(ListFolderNodeViewModel node)
+ {
+ try
+ {
+ await _session.Api.DeleteListFolderAsync(node.Id);
+ StatusMessage = $"Deleted “{node.Name}” and any subfolders.";
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex) { ErrorMessage = ex.Message; }
+ }
+}
+
+/// One folder in the tree, with its children.
+public partial class ListFolderNodeViewModel : ObservableObject
+{
+ private readonly ListFolderPanelViewModel _owner;
+
+ public string Id { get; }
+ public string Name { get; }
+ public string? ParentId { get; }
+ public ObservableCollection Children { get; }
+ public bool HasChildren => Children.Count > 0;
+
+ [ObservableProperty]
+ private bool isExpanded = true;
+
+ [ObservableProperty]
+ private bool isRenaming;
+
+ [ObservableProperty]
+ private string renameText = "";
+
+ /// Armed by a first press — delete cascades to subfolders.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeletePrompt))]
+ private bool isDeleteArmed;
+
+ public string DeletePrompt => IsDeleteArmed
+ ? (HasChildren ? "Delete this and its subfolders?" : "Delete?")
+ : "Delete";
+
+ public ListFolderNodeViewModel(
+ ListFolder folder, List children, ListFolderPanelViewModel owner)
+ {
+ Id = folder.Id;
+ Name = folder.Name;
+ ParentId = folder.ParentId;
+ Children = new ObservableCollection(children);
+ _owner = owner;
+ renameText = folder.Name;
+ }
+
+ [RelayCommand]
+ private void BeginRename()
+ {
+ RenameText = Name;
+ IsRenaming = true;
+ }
+
+ [RelayCommand]
+ private void CancelRename() => IsRenaming = false;
+
+ [RelayCommand]
+ private async Task CommitRename()
+ {
+ IsRenaming = false;
+ await _owner.RenameAsync(this, RenameText);
+ }
+
+ [RelayCommand]
+ private async Task Delete()
+ {
+ // Two-step: the cascade to subfolders isn't obvious, and a native
+ // confirm dialog would block the dispatcher.
+ if (!IsDeleteArmed)
+ {
+ IsDeleteArmed = true;
+ return;
+ }
+ IsDeleteArmed = false;
+ await _owner.DeleteAsync(this);
+ }
+
+ [RelayCommand]
+ private Task MoveToRoot() => _owner.MoveAsync(this, null);
+
+ [RelayCommand]
+ private Task MoveTo(ListFolder target) => _owner.MoveAsync(this, target?.Id);
+}
diff --git a/InterlinedList/Views/ListFolderPanel.xaml b/InterlinedList/Views/ListFolderPanel.xaml
new file mode 100644
index 0000000..7893ac8
--- /dev/null
+++ b/InterlinedList/Views/ListFolderPanel.xaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/ListFolderPanel.xaml.cs b/InterlinedList/Views/ListFolderPanel.xaml.cs
new file mode 100644
index 0000000..2fde989
--- /dev/null
+++ b/InterlinedList/Views/ListFolderPanel.xaml.cs
@@ -0,0 +1,27 @@
+using System.Windows.Controls;
+using InterlinedList.Services;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// List-folder tree with create / rename / move / delete.
+///
+///
+/// Self-contained so it can be hosted with one line — ListsView is being
+/// reworked across the #161/#163/#164 stack.
+///
+public partial class ListFolderPanel : UserControl
+{
+ private readonly ListFolderPanelViewModel _vm;
+
+ public ListFolderPanel()
+ {
+ InitializeComponent();
+ _vm = new ListFolderPanelViewModel(AppServices.Session);
+ DataContext = _vm;
+ }
+
+ /// Load (or reload) the folder tree.
+ public void Refresh() => _ = _vm.LoadCommand.ExecuteAsync(null);
+}