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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user