Add nested (hierarchical) conversation folders

Folders can now contain other folders, arbitrarily deep — e.g. "Work"
containing "Project A"/"Project B". v10 migration adds a
self-referencing parentId column; tree ordering, depth, and cycle
detection are pure Swift (Folder.orderedTree/isDescendant/
visibleFolderIds), not SQL, so listFolders() stays a simple flat
query.

- Create nested folders via "New Subfolder…" (context menu, both
  list views) or by dragging a folder onto another to reparent it.
  Dragging onto an existing descendant is rejected (cycle guard).
- Deleting a folder reparents its children and any conversations
  filed directly in it up one level to the deleted folder's own
  parent — conversations are never deleted. This also fixes a real
  bug: the previous deleteFolder never persisted unfiling to the
  database, only patched in-memory state, so a conversation whose
  folder was deleted kept a dangling folderId and silently vanished
  from view after the next relaunch.
- All "Move to Folder" pickers (sidebar, advanced list, per-row
  context menus, the Save dialog's folder popup) show an indented
  flat list reflecting the tree.
- New DraggedItem enum disambiguates a dragged folder from dragged
  conversation(s) in the shared string-based drag payload, and
  unifies both list views on the same bundled-multi-selection format
  — closes a gap where dragging a multi-selection in the advanced
  list (⌘L) only moved the one row grabbed, unlike the sidebar.

Confirmed working live, including relaunch-survival of the
delete/reparent fix.
This commit is contained in:
2026-08-02 17:52:10 +02:00
parent 7f5d858b2a
commit 2668555b98
8 changed files with 598 additions and 99 deletions
+81
View File
@@ -231,6 +231,68 @@ struct DatabaseServiceFolderTests {
#expect(db.columnNames(in: "conversations").contains("folderId"))
}
@Test("v10 adds parentId to folders")
func v10AddsParentId() {
let db = DatabaseService.makeInMemory()
#expect(db.columnNames(in: "folders").contains("parentId"))
}
@Test("createFolder(parentId:) nests the new folder under its parent")
func createFolderWithParent() throws {
let db = DatabaseService.makeInMemory()
let parent = try db.createFolder(name: "Work")
let child = try db.createFolder(name: "Project A", parentId: parent.id)
#expect(child.parentId == parent.id)
let folders = try db.listFolders()
#expect(folders.first(where: { $0.id == child.id })?.parentId == parent.id)
}
@Test("moveFolder reparents a folder")
func moveFolderReparents() throws {
let db = DatabaseService.makeInMemory()
let workFolder = try db.createFolder(name: "Work")
let personalFolder = try db.createFolder(name: "Personal")
try db.moveFolder(id: personalFolder.id, toParent: workFolder.id)
let folders = try db.listFolders()
#expect(folders.first(where: { $0.id == personalFolder.id })?.parentId == workFolder.id)
}
@Test("moveFolder promotes a nested folder to top-level when given nil")
func moveFolderPromotesToTopLevel() throws {
let db = DatabaseService.makeInMemory()
let parent = try db.createFolder(name: "Work")
let child = try db.createFolder(name: "Project A", parentId: parent.id)
try db.moveFolder(id: child.id, toParent: nil)
let folders = try db.listFolders()
#expect(folders.first(where: { $0.id == child.id })?.parentId == nil)
}
@Test("moveFolder throws wouldCreateCycle when reparenting a folder under itself")
func moveFolderSelfReparentThrows() throws {
let db = DatabaseService.makeInMemory()
let folder = try db.createFolder(name: "Work")
#expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
try db.moveFolder(id: folder.id, toParent: folder.id)
}
}
@Test("moveFolder throws wouldCreateCycle when reparenting an ancestor under its own descendant")
func moveFolderAncestorUnderDescendantThrows() throws {
let db = DatabaseService.makeInMemory()
let grandparent = try db.createFolder(name: "Work")
let parent = try db.createFolder(name: "Project A", parentId: grandparent.id)
let child = try db.createFolder(name: "Sub-task", parentId: parent.id)
#expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
try db.moveFolder(id: grandparent.id, toParent: child.id)
}
}
@Test("Creating folders assigns increasing sort order")
func createFolderAssignsSortOrder() throws {
let db = DatabaseService.makeInMemory()
@@ -303,6 +365,25 @@ struct DatabaseServiceFolderTests {
#expect(loaded?.0.folderId == nil)
}
@Test("Deleting a nested folder reparents its children and conversations up one level, not to top-level")
func deleteNestedFolderReparentsUpOneLevel() throws {
let db = DatabaseService.makeInMemory()
let work = try db.createFolder(name: "Work")
let projectA = try db.createFolder(name: "Project A", parentId: work.id)
let subTask = try db.createFolder(name: "Sub-task", parentId: projectA.id)
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.moveConversation(id: conversation.id, toFolder: projectA.id)
try db.deleteFolder(id: projectA.id)
let folders = try db.listFolders()
#expect(folders.first(where: { $0.id == subTask.id })?.parentId == work.id)
#expect(folders.contains(where: { $0.id == projectA.id }) == false)
let loaded = try db.loadConversation(id: conversation.id)
#expect(loaded?.0.folderId == work.id)
}
@Test("listConversations reflects folderId")
func listConversationsReflectsFolderId() throws {
let db = DatabaseService.makeInMemory()
+177
View File
@@ -0,0 +1,177 @@
//
// FolderPureLogicTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("Folder tree pure logic")
struct FolderOrderedTreeTests {
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
Folder(name: name, parentId: parentId)
}
@Test("Single root folder with no children")
func singleRoot() {
let work = makeFolder("Work")
let result = Folder.orderedTree(from: [work])
#expect(result.map { $0.folder.id } == [work.id])
#expect(result.map(\.depth) == [0])
}
@Test("Multiple top-level roots preserve their relative order")
func multipleRootsPreserveOrder() {
let work = makeFolder("Work")
let personal = makeFolder("Personal")
let result = Folder.orderedTree(from: [work, personal])
#expect(result.map { $0.folder.id } == [work.id, personal.id])
#expect(result.map(\.depth) == [0, 0])
}
@Test("Parent and children interleave depth-first, not grouped by level")
func parentChildInterleaveDepthFirst() {
let work = makeFolder("Work")
let projectA = makeFolder("Project A", parentId: work.id)
let projectB = makeFolder("Project B", parentId: work.id)
let personal = makeFolder("Personal")
let result = Folder.orderedTree(from: [work, projectA, projectB, personal])
#expect(result.map { $0.folder.id } == [work.id, projectA.id, projectB.id, personal.id])
#expect(result.map(\.depth) == [0, 1, 1, 0])
}
@Test("Deep nesting (4-5 levels) reports correct depths")
func deepNestingDepths() {
let l0 = makeFolder("L0")
let l1 = makeFolder("L1", parentId: l0.id)
let l2 = makeFolder("L2", parentId: l1.id)
let l3 = makeFolder("L3", parentId: l2.id)
let l4 = makeFolder("L4", parentId: l3.id)
let result = Folder.orderedTree(from: [l0, l1, l2, l3, l4])
#expect(result.map(\.depth) == [0, 1, 2, 3, 4])
}
@Test("Empty input produces an empty tree")
func emptyInput() {
#expect(Folder.orderedTree(from: []).isEmpty)
}
}
@Suite("Folder.isDescendant")
struct FolderIsDescendantTests {
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
Folder(name: name, parentId: parentId)
}
@Test("A direct child is a descendant of its parent")
func directChild() {
let parent = makeFolder("Work")
let child = makeFolder("Project A", parentId: parent.id)
#expect(Folder.isDescendant(child.id, of: parent.id, in: [parent, child]))
}
@Test("A grandchild is a descendant of its grandparent")
func grandchild() {
let grandparent = makeFolder("Work")
let parent = makeFolder("Project A", parentId: grandparent.id)
let child = makeFolder("Sub-task", parentId: parent.id)
#expect(Folder.isDescendant(child.id, of: grandparent.id, in: [grandparent, parent, child]))
}
@Test("An unrelated folder is not a descendant")
func unrelatedFolder() {
let work = makeFolder("Work")
let personal = makeFolder("Personal")
#expect(Folder.isDescendant(personal.id, of: work.id, in: [work, personal]) == false)
}
@Test("A folder is considered a descendant of itself (rejects self-drop)")
func selfReference() {
let work = makeFolder("Work")
#expect(Folder.isDescendant(work.id, of: work.id, in: [work]))
}
@Test("A candidate ID absent from the folder list returns false without crashing")
func candidateAbsent() {
let work = makeFolder("Work")
#expect(Folder.isDescendant(UUID(), of: work.id, in: [work]) == false)
}
}
@Suite("Folder.visibleFolderIds")
struct FolderVisibleIdsTests {
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
Folder(name: name, parentId: parentId)
}
@Test("No collapsed folders means everything is visible")
func noCollapsedAllVisible() {
let work = makeFolder("Work")
let projectA = makeFolder("Project A", parentId: work.id)
let tree = Folder.orderedTree(from: [work, projectA])
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [])
#expect(visible == Set([work.id, projectA.id]))
}
@Test("Collapsing a mid-depth folder hides all deeper descendants but keeps its own id visible")
func collapsingMidDepthHidesDescendants() {
let work = makeFolder("Work")
let projectA = makeFolder("Project A", parentId: work.id)
let subTask = makeFolder("Sub-task", parentId: projectA.id)
let tree = Folder.orderedTree(from: [work, projectA, subTask])
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [projectA.id])
#expect(visible == Set([work.id, projectA.id]))
#expect(!visible.contains(subTask.id))
}
@Test("Two unrelated collapsed folders hide their own subtrees independently")
func independentCollapsedSubtrees() {
let work = makeFolder("Work")
let projectA = makeFolder("Project A", parentId: work.id)
let personal = makeFolder("Personal")
let hobby = makeFolder("Hobby", parentId: personal.id)
let tree = Folder.orderedTree(from: [work, projectA, personal, hobby])
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [work.id, personal.id])
#expect(visible == Set([work.id, personal.id]))
}
}
@Suite("DraggedItem")
struct DraggedItemTests {
@Test("A single conversation round-trips through encode/decode")
func singleConversationRoundTrip() {
let id = UUID()
let item = DraggedItem.conversations([id])
#expect(DraggedItem(rawValue: item.rawValue) == item)
}
@Test("Multiple bundled conversations round-trip through encode/decode")
func multipleConversationsRoundTrip() {
let ids = [UUID(), UUID(), UUID()]
let item = DraggedItem.conversations(ids)
guard case .conversations(let decoded) = DraggedItem(rawValue: item.rawValue) else {
Issue.record("Expected .conversations case")
return
}
#expect(decoded == ids)
}
@Test("A folder round-trips through encode/decode")
func folderRoundTrip() {
let id = UUID()
let item = DraggedItem.folder(id)
#expect(DraggedItem(rawValue: item.rawValue) == item)
}
@Test("A garbage string decodes to nil")
func garbageStringIsNil() {
#expect(DraggedItem(rawValue: "not-a-valid-payload") == nil)
}
}