From 2668555b9844f5a95a61a9870e5c41dbdbeaf322 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Sun, 2 Aug 2026 17:52:10 +0200 Subject: [PATCH] Add nested (hierarchical) conversation folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- oAI/Models/DraggedItem.swift | 51 ++++++ oAI/Models/Folder.swift | 56 +++++- oAI/Services/DatabaseService.swift | 69 +++++++- oAI/ViewModels/ChatViewModel.swift | 12 +- oAI/Views/Main/SidebarView.swift | 122 ++++++++----- oAI/Views/Screens/ConversationListView.swift | 129 ++++++++++---- oAITests/DatabaseServiceTests.swift | 81 +++++++++ oAITests/FolderPureLogicTests.swift | 177 +++++++++++++++++++ 8 files changed, 598 insertions(+), 99 deletions(-) create mode 100644 oAI/Models/DraggedItem.swift create mode 100644 oAITests/FolderPureLogicTests.swift diff --git a/oAI/Models/DraggedItem.swift b/oAI/Models/DraggedItem.swift new file mode 100644 index 0000000..fe42f54 --- /dev/null +++ b/oAI/Models/DraggedItem.swift @@ -0,0 +1,51 @@ +// +// DraggedItem.swift +// Confab +// +// Drag-and-drop payload wire format for the conversation lists +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of Confab. +// +// Confab is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling Confab or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + +import Foundation + +/// Disambiguates what's being dragged in the sidebar/advanced-list conversation trees, now that +/// both conversations and folders are draggable. `.conversations` carries one or more IDs — a +/// single drag, or every ID in an active multi-selection bundled together so dropping any one of +/// them moves the whole selection. +enum DraggedItem: Equatable { + case conversations([UUID]) + case folder(UUID) + + var rawValue: String { + switch self { + case .conversations(let ids): return "conversation:" + ids.map(\.uuidString).joined(separator: ",") + case .folder(let id): return "folder:\(id.uuidString)" + } + } + + init?(rawValue: String) { + if rawValue.hasPrefix("conversation:") { + let ids = rawValue.dropFirst(13).split(separator: ",").compactMap { UUID(uuidString: String($0)) } + guard !ids.isEmpty else { return nil } + self = .conversations(ids) + } else if rawValue.hasPrefix("folder:"), let id = UUID(uuidString: String(rawValue.dropFirst(7))) { + self = .folder(id) + } else { + return nil + } + } +} diff --git a/oAI/Models/Folder.swift b/oAI/Models/Folder.swift index d3bd80c..377040c 100644 --- a/oAI/Models/Folder.swift +++ b/oAI/Models/Folder.swift @@ -28,16 +28,70 @@ struct Folder: Identifiable, Codable, Sendable { var name: String var sortOrder: Int let createdAt: Date + var parentId: UUID? nonisolated init( id: UUID = UUID(), name: String, sortOrder: Int = 0, - createdAt: Date = Date() + createdAt: Date = Date(), + parentId: UUID? = nil ) { self.id = id self.name = name self.sortOrder = sortOrder self.createdAt = createdAt + self.parentId = parentId + } +} + +extension Folder { + /// Depth-first, indented ordering for flat-list display. Assumes `folders` already has the + /// desired sibling order (e.g. listFolders()'s alphabetical order) — only re-groups by + /// parent/child, preserving each existing sibling ordering. + nonisolated static func orderedTree(from folders: [Folder]) -> [(folder: Folder, depth: Int)] { + var childrenByParent: [UUID?: [Folder]] = [:] + for folder in folders { + childrenByParent[folder.parentId, default: []].append(folder) + } + var result: [(folder: Folder, depth: Int)] = [] + func walk(parentId: UUID?, depth: Int, visiting: Set) { + for folder in childrenByParent[parentId] ?? [] { + guard !visiting.contains(folder.id) else { continue } // defensive cycle guard + result.append((folder, depth)) + walk(parentId: folder.id, depth: depth + 1, visiting: visiting.union([folder.id])) + } + } + walk(parentId: nil, depth: 0, visiting: []) + return result + } + + /// True if `candidateId` is `ancestorId` itself, or nested anywhere below it. A single call + /// `isDescendant(target.id, of: source.id, in: folders)` rejects both a self-drop (target == + /// source) and any deeper cycle (target currently lives under source). + nonisolated static func isDescendant(_ candidateId: UUID, of ancestorId: UUID, in folders: [Folder]) -> Bool { + var current: UUID? = candidateId + var visited: Set = [] + while let id = current, !visited.contains(id) { + if id == ancestorId { return true } + visited.insert(id) + current = folders.first(where: { $0.id == id })?.parentId + } + return false + } + + /// Given an ordered tree and the set of explicitly-collapsed folder ids, returns ids whose + /// header should still render — collapsing a folder hides its whole subtree, but its own + /// header stays visible so it can be expanded again. + nonisolated static func visibleFolderIds(tree: [(folder: Folder, depth: Int)], collapsed: Set) -> Set { + var visible: Set = [] + var hiddenAtOrBelowDepth: Int? = nil + for (folder, depth) in tree { + if let hiddenDepth = hiddenAtOrBelowDepth, depth > hiddenDepth { continue } + hiddenAtOrBelowDepth = nil + visible.insert(folder.id) + if collapsed.contains(folder.id) { hiddenAtOrBelowDepth = depth } + } + return visible } } diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index e291cd7..272624a 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -45,6 +45,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable { var name: String var sortOrder: Int var createdAt: String + var parentId: String? } struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { @@ -366,6 +367,20 @@ final class DatabaseService: Sendable { } } + migrator.registerMigration("v10") { db in + // Nested folders: a folder may live under another folder. ON DELETE RESTRICT (not + // CASCADE/SET NULL) is a defensive backstop — deleteFolder() always reparents + // children/conversations before deleting the row, in one transaction, so by the time + // DELETE runs nothing should reference it. RESTRICT throws loudly if that invariant + // is ever violated, instead of silently promoting things to top-level or cascading a + // delete through a whole subtree. + try db.alter(table: "folders") { t in + t.add(column: "parentId", .text) + .references("folders", onDelete: .restrict) + } + try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"]) + } + return migrator } @@ -606,13 +621,18 @@ final class DatabaseService: Sendable { // MARK: - Folders - nonisolated func createFolder(name: String) throws -> Folder { - let folder = Folder(name: name, sortOrder: try nextFolderSortOrder()) + enum FolderError: Error, Sendable { + case wouldCreateCycle + } + + nonisolated func createFolder(name: String, parentId: UUID? = nil) throws -> Folder { + let folder = Folder(name: name, sortOrder: try nextFolderSortOrder(), parentId: parentId) let record = FolderRecord( id: folder.id.uuidString, name: folder.name, sortOrder: folder.sortOrder, - createdAt: Self.isoString(from: folder.createdAt) + createdAt: Self.isoString(from: folder.createdAt), + parentId: parentId?.uuidString ) try dbQueue.write { db in try record.insert(db) @@ -637,8 +657,34 @@ final class DatabaseService: Sendable { } } + /// Reparents a folder (nil = promote to top level). Throws `.wouldCreateCycle` if `parentId` + /// is the folder itself or one of its own descendants. + nonisolated func moveFolder(id: UUID, toParent parentId: UUID?) throws { + try dbQueue.write { db in + if let parentId { + guard parentId != id else { throw FolderError.wouldCreateCycle } + let folders = try FolderRecord.fetchAll(db).compactMap(Self.folder(from:)) + guard !Folder.isDescendant(parentId, of: id, in: folders) else { + throw FolderError.wouldCreateCycle + } + } + try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?", + arguments: [parentId?.uuidString, id.uuidString]) + } + } + + /// Deletes a folder, reparenting everything directly inside it (child folders + conversations + /// filed directly in it) up one level to the deleted folder's own parent. Conversations are + /// never deleted. All statements run in one transaction, satisfying the ON DELETE RESTRICT + /// backstop (reparent happens before the DELETE). nonisolated func deleteFolder(id: UUID) throws { try dbQueue.write { db in + guard let ownRecord = try FolderRecord.fetchOne(db, key: id.uuidString) else { return } + let parentIdString = ownRecord.parentId + try db.execute(sql: "UPDATE folders SET parentId = ? WHERE parentId = ?", + arguments: [parentIdString, id.uuidString]) + try db.execute(sql: "UPDATE conversations SET folderId = ? WHERE folderId = ?", + arguments: [parentIdString, id.uuidString]) _ = try FolderRecord.deleteOne(db, key: id.uuidString) } } @@ -648,15 +694,20 @@ final class DatabaseService: Sendable { // Alphabetical, not creation order (sortOrder) — folders should sort // predictably by name everywhere they're listed. let records = try FolderRecord.fetchAll(db, sql: "SELECT * FROM folders ORDER BY name COLLATE NOCASE") - return records.compactMap { record -> Folder? in - guard let id = UUID(uuidString: record.id), - let createdAt = Self.isoDate(from: record.createdAt) - else { return nil } - return Folder(id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt) - } + return records.compactMap(Self.folder(from:)) } } + private nonisolated static func folder(from record: FolderRecord) -> Folder? { + guard let id = UUID(uuidString: record.id), + let createdAt = Self.isoDate(from: record.createdAt) + else { return nil } + return Folder( + id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt, + parentId: record.parentId.flatMap { UUID(uuidString: $0) } + ) + } + nonisolated func moveConversation(id: UUID, toFolder folderId: UUID?) throws { try dbQueue.write { db in try db.execute( diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 47e6a27..627d5e9 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -35,6 +35,7 @@ private final class ConversationSaveAccessory: NSObject { let nameField: NSTextField private let folderPopup: NSPopUpButton private var folders: [Folder] + private var entries: [(folder: Folder, depth: Int)] = [] // recomputed in rebuildMenu whenever folders changes private var lastGoodSelection: UUID? // the folder to revert to if "New Folder…" is cancelled init(defaultName: String, folders: [Folder], selectedFolderId: UUID?) { @@ -56,15 +57,16 @@ private final class ConversationSaveAccessory: NSObject { } private func rebuildMenu(selecting folderId: UUID?) { + entries = Folder.orderedTree(from: folders) folderPopup.removeAllItems() folderPopup.addItem(withTitle: "No Folder") - for folder in folders { - folderPopup.addItem(withTitle: folder.name) + for entry in entries { + folderPopup.addItem(withTitle: String(repeating: " ", count: entry.depth) + entry.folder.name) } folderPopup.menu?.addItem(.separator()) folderPopup.addItem(withTitle: "New Folder…") - if let folderId, let idx = folders.firstIndex(where: { $0.id == folderId }) { + if let folderId, let idx = entries.firstIndex(where: { $0.folder.id == folderId }) { folderPopup.selectItem(at: idx + 1) } else { folderPopup.selectItem(at: 0) @@ -104,8 +106,8 @@ private final class ConversationSaveAccessory: NSObject { var resolvedFolderId: UUID? { let idx = folderPopup.indexOfSelectedItem - guard idx >= 1, idx - 1 < folders.count else { return nil } - return folders[idx - 1].id + guard idx >= 1, idx - 1 < entries.count else { return nil } + return entries[idx - 1].folder.id } } #endif diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index 4bebeb9..25d9973 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -44,14 +44,17 @@ struct SidebarView: View { Dictionary(grouping: filteredConversations, by: { $0.folderId }) } + private var orderedFolderTree: [(folder: Folder, depth: Int)] { Folder.orderedTree(from: folders) } + private var visibleFolderIds: Set { Folder.visibleFolderIds(tree: orderedFolderTree, collapsed: collapsedFolders) } + /// Flattened conversation order matching what's actually rendered in the List — folders in - /// order (skipping collapsed ones' contents, since they're not visible/selectable), then - /// Unfiled last. Used as the anchor sequence for Shift-click range selection, same pattern as - /// ConversationListView's version. + /// depth-first tree order (skipping collapsed ones' contents, since they're not + /// visible/selectable), then Unfiled last. Used as the anchor sequence for Shift-click range + /// selection, same pattern as ConversationListView's version. private var visibleOrderedConversations: [Conversation] { guard !folders.isEmpty else { return filteredConversations } var result: [Conversation] = [] - for folder in folders where !collapsedFolders.contains(folder.id) { + for (folder, _) in orderedFolderTree where visibleFolderIds.contains(folder.id) && !collapsedFolders.contains(folder.id) { result.append(contentsOf: conversationsByFolder[folder.id] ?? []) } result.append(contentsOf: conversationsByFolder[nil] ?? []) @@ -71,9 +74,9 @@ struct SidebarView: View { Menu { if !folders.isEmpty { - ForEach(folders) { folder in - Button(folder.name) { - moveSelectedToFolder(folder.id) + ForEach(orderedFolderTree, id: \.folder.id) { entry in + Button(String(repeating: " ", count: entry.depth) + entry.folder.name) { + moveSelectedToFolder(entry.folder.id) } } Divider() @@ -182,17 +185,19 @@ struct SidebarView: View { .listStyle(.sidebar) } else { List { - ForEach(folders) { folder in - let folderConversations = conversationsByFolder[folder.id] ?? [] - if !folderConversations.isEmpty || searchText.isEmpty { - Section { - if !collapsedFolders.contains(folder.id) { - ForEach(folderConversations) { conversation in - conversationRow(conversation) + ForEach(orderedFolderTree, id: \.folder.id) { entry in + if visibleFolderIds.contains(entry.folder.id) { + let folderConversations = conversationsByFolder[entry.folder.id] ?? [] + if !folderConversations.isEmpty || searchText.isEmpty { + Section { + if !collapsedFolders.contains(entry.folder.id) { + ForEach(folderConversations) { conversation in + conversationRow(conversation) + } } + } header: { + folderHeader(entry.folder, depth: entry.depth) } - } header: { - folderHeader(folder) } } } @@ -229,7 +234,7 @@ struct SidebarView: View { } @ViewBuilder - private func folderHeader(_ folder: Folder) -> some View { + private func folderHeader(_ folder: Folder, depth: Int) -> some View { HStack(spacing: 4) { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) @@ -237,6 +242,7 @@ struct SidebarView: View { Text(folder.name) .font(.system(size: 12, weight: .bold)) } + .padding(.leading, CGFloat(depth) * 14) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { @@ -244,6 +250,11 @@ struct SidebarView: View { } } .contextMenu { + Button { + createFolderPrompt(parentId: folder.id) + } label: { + Label("New Subfolder…", systemImage: "folder.badge.plus") + } Button { renameFolderPrompt(folder) } label: { @@ -255,6 +266,14 @@ struct SidebarView: View { Label("Delete Folder", systemImage: "trash") } } + .draggable(DraggedItem.folder(folder.id).rawValue) { + Text(folder.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + } .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: folder.id) } @@ -269,17 +288,28 @@ struct SidebarView: View { SettingsService.shared.collapsedFolderIds = collapsedFolders } - private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { + private func handleDrop(_ items: [String], toFolder targetFolderId: UUID?) -> Bool { var moved = false - // Each dropped item may itself be a newline-joined bundle of IDs (a multi-selection - // dragged together — see conversationRow's .draggable payload), so split every item - // before parsing UUIDs out of it. - for idString in items.flatMap({ $0.split(separator: "\n") }) { - guard let id = UUID(uuidString: String(idString)), - let conversation = conversations.first(where: { $0.id == id }) - else { continue } - moveConversation(conversation, toFolder: folderId) - moved = true + for raw in items { + guard let item = DraggedItem(rawValue: raw) else { continue } + switch item { + case .conversations(let ids): + for id in ids { + guard let conversation = conversations.first(where: { $0.id == id }) else { continue } + moveConversation(conversation, toFolder: targetFolderId) + moved = true + } + case .folder(let sourceId): + guard sourceId != targetFolderId else { continue } + if let targetFolderId, Folder.isDescendant(targetFolderId, of: sourceId, in: folders) { continue } + do { + try DatabaseService.shared.moveFolder(id: sourceId, toParent: targetFolderId) + if let i = folders.firstIndex(where: { $0.id == sourceId }) { folders[i].parentId = targetFolderId } + moved = true + } catch { + Log.db.error("Failed to move folder: \(error.localizedDescription)") + } + } } return moved } @@ -302,7 +332,10 @@ struct SidebarView: View { ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35) : Color.clear ) - .draggable(dragPayload(for: conversation)) { + .draggable(DraggedItem.conversations( + selectedConversations.contains(conversation.id) && selectedConversations.count > 1 + ? Array(selectedConversations) : [conversation.id] + ).rawValue) { if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { Text("\(selectedConversations.count) conversations") .font(.system(size: 13, weight: .medium)) @@ -342,12 +375,12 @@ struct SidebarView: View { } Divider() } - ForEach(folders) { folder in - if folder.id != conversation.folderId { + ForEach(orderedFolderTree, id: \.folder.id) { entry in + if entry.folder.id != conversation.folderId { Button { - moveConversationOrSelection(conversation, toFolder: folder.id) + moveConversationOrSelection(conversation, toFolder: entry.folder.id) } label: { - Text(folder.name) + Text(String(repeating: " ", count: entry.depth) + entry.folder.name) } } } @@ -445,16 +478,6 @@ struct SidebarView: View { } } - /// Drag payload for a row: if it's part of an active multi-selection, bundle every selected - /// conversation's ID (newline-joined — UUIDs never contain one) so dragging any one of them - /// moves the whole selection. Otherwise just this row's own ID, unchanged. - private func dragPayload(for conversation: Conversation) -> String { - if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { - return selectedConversations.map { $0.uuidString }.joined(separator: "\n") - } - return conversation.id.uuidString - } - /// Standard macOS row-click handling: ⌘-click toggles the individual row (additive), Shift-click /// extends/creates a contiguous range from the last-clicked row, and a plain click replaces the /// selection with just this row. Never opens the conversation — that's double-click's job. @@ -483,10 +506,10 @@ struct SidebarView: View { lastClickedId = conversation.id } - private func createFolderPrompt(andMove conversation: Conversation? = nil) { + private func createFolderPrompt(andMove conversation: Conversation? = nil, parentId: UUID? = nil) { #if os(macOS) let alert = NSAlert() - alert.messageText = "New Folder" + alert.messageText = parentId == nil ? "New Folder" : "New Subfolder" alert.addButton(withTitle: "Create") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) @@ -496,7 +519,7 @@ struct SidebarView: View { let name = input.stringValue.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } do { - let folder = try DatabaseService.shared.createFolder(name: name) + let folder = try DatabaseService.shared.createFolder(name: name, parentId: parentId) folders.append(folder) sortFolders() if let conversation = conversation { @@ -541,9 +564,16 @@ struct SidebarView: View { private func deleteFolder(_ folder: Folder) { do { try DatabaseService.shared.deleteFolder(id: folder.id) + // Matches the DB's reparent-up-one-level semantics: children and conversations + // filed directly in this folder move to its own parent (nil if it was top-level), + // not blanket-unfiled. + let parentId = folder.parentId folders.removeAll { $0.id == folder.id } + for i in folders.indices where folders[i].parentId == folder.id { + folders[i].parentId = parentId + } for i in conversations.indices where conversations[i].folderId == folder.id { - conversations[i].folderId = nil + conversations[i].folderId = parentId } } catch { Log.db.error("Failed to delete folder: \(error.localizedDescription)") diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 4df7e6f..4c67726 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -61,13 +61,17 @@ struct ConversationListView: View { Dictionary(grouping: filteredConversations, by: { $0.folderId }) } + private var orderedFolderTree: [(folder: Folder, depth: Int)] { Folder.orderedTree(from: folders) } + private var visibleFolderIds: Set { Folder.visibleFolderIds(tree: orderedFolderTree, collapsed: collapsedFolders) } + /// Flattened conversation order matching what's actually rendered in the List — folders in - /// order (skipping collapsed ones' contents, since they're not visible/selectable), then - /// Unfiled last. Used as the anchor sequence for Shift-click range selection. + /// depth-first tree order (skipping collapsed ones' contents, since they're not + /// visible/selectable), then Unfiled last. Used as the anchor sequence for Shift-click range + /// selection. private var visibleOrderedConversations: [Conversation] { guard !folders.isEmpty else { return filteredConversations } var result: [Conversation] = [] - for folder in folders where !collapsedFolders.contains(folder.id) { + for (folder, _) in orderedFolderTree where visibleFolderIds.contains(folder.id) && !collapsedFolders.contains(folder.id) { result.append(contentsOf: conversationsByFolder[folder.id] ?? []) } result.append(contentsOf: conversationsByFolder[nil] ?? []) @@ -105,9 +109,9 @@ struct ConversationListView: View { if !selectedConversations.isEmpty { Menu { if !folders.isEmpty { - ForEach(folders) { folder in - Button(folder.name) { - moveSelectedToFolder(folder.id) + ForEach(orderedFolderTree, id: \.folder.id) { entry in + Button(String(repeating: " ", count: entry.depth) + entry.folder.name) { + moveSelectedToFolder(entry.folder.id) } } Divider() @@ -255,17 +259,19 @@ struct ConversationListView: View { conversationRow(conversation) } } else { - ForEach(folders) { folder in - let folderConversations = conversationsByFolder[folder.id] ?? [] - if !folderConversations.isEmpty || searchText.isEmpty { - Section { - if !collapsedFolders.contains(folder.id) { - ForEach(folderConversations) { conversation in - conversationRow(conversation) + ForEach(orderedFolderTree, id: \.folder.id) { entry in + if visibleFolderIds.contains(entry.folder.id) { + let folderConversations = conversationsByFolder[entry.folder.id] ?? [] + if !folderConversations.isEmpty || searchText.isEmpty { + Section { + if !collapsedFolders.contains(entry.folder.id) { + ForEach(folderConversations) { conversation in + conversationRow(conversation) + } } + } header: { + folderHeader(entry.folder, depth: entry.depth) } - } header: { - folderHeader(folder) } } } @@ -382,13 +388,25 @@ struct ConversationListView: View { : Color.clear ) .id(conversation.id) - .draggable(conversation.id.uuidString) { - Text(conversation.name) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + .draggable(DraggedItem.conversations( + isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 + ? Array(selectedConversations) : [conversation.id] + ).rawValue) { + if isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { + Text("\(selectedConversations.count) conversations") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + } else { + Text(conversation.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { @@ -421,12 +439,12 @@ struct ConversationListView: View { } Divider() } - ForEach(folders) { folder in - if folder.id != conversation.folderId { + ForEach(orderedFolderTree, id: \.folder.id) { entry in + if entry.folder.id != conversation.folderId { Button { - moveConversationOrSelection(conversation, toFolder: folder.id) + moveConversationOrSelection(conversation, toFolder: entry.folder.id) } label: { - Text(folder.name) + Text(String(repeating: " ", count: entry.depth) + entry.folder.name) } } } @@ -467,7 +485,7 @@ struct ConversationListView: View { } @ViewBuilder - private func folderHeader(_ folder: Folder) -> some View { + private func folderHeader(_ folder: Folder, depth: Int) -> some View { HStack(spacing: 4) { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) @@ -475,6 +493,7 @@ struct ConversationListView: View { Text(folder.name) .font(.system(size: 12, weight: .bold)) } + .padding(.leading, CGFloat(depth) * 14) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { @@ -482,6 +501,11 @@ struct ConversationListView: View { } } .contextMenu { + Button { + createFolderPrompt(parentId: folder.id) + } label: { + Label("New Subfolder…", systemImage: "folder.badge.plus") + } Button { renameFolderPrompt(folder) } label: { @@ -493,6 +517,14 @@ struct ConversationListView: View { Label("Delete Folder", systemImage: "trash") } } + .draggable(DraggedItem.folder(folder.id).rawValue) { + Text(folder.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + } .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: folder.id) } @@ -507,22 +539,36 @@ struct ConversationListView: View { SettingsService.shared.collapsedFolderIds = collapsedFolders } - private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { + private func handleDrop(_ items: [String], toFolder targetFolderId: UUID?) -> Bool { var moved = false - for idString in items { - guard let id = UUID(uuidString: idString), - let conversation = conversations.first(where: { $0.id == id }) - else { continue } - moveConversation(conversation, toFolder: folderId) - moved = true + for raw in items { + guard let item = DraggedItem(rawValue: raw) else { continue } + switch item { + case .conversations(let ids): + for id in ids { + guard let conversation = conversations.first(where: { $0.id == id }) else { continue } + moveConversation(conversation, toFolder: targetFolderId) + moved = true + } + case .folder(let sourceId): + guard sourceId != targetFolderId else { continue } + if let targetFolderId, Folder.isDescendant(targetFolderId, of: sourceId, in: folders) { continue } + do { + try DatabaseService.shared.moveFolder(id: sourceId, toParent: targetFolderId) + if let i = folders.firstIndex(where: { $0.id == sourceId }) { folders[i].parentId = targetFolderId } + moved = true + } catch { + Log.db.error("Failed to move folder: \(error.localizedDescription)") + } + } } return moved } - private func createFolderPrompt() { + private func createFolderPrompt(parentId: UUID? = nil) { #if os(macOS) let alert = NSAlert() - alert.messageText = "New Folder" + alert.messageText = parentId == nil ? "New Folder" : "New Subfolder" alert.addButton(withTitle: "Create") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) @@ -532,7 +578,7 @@ struct ConversationListView: View { let name = input.stringValue.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } do { - let folder = try DatabaseService.shared.createFolder(name: name) + let folder = try DatabaseService.shared.createFolder(name: name, parentId: parentId) folders.append(folder) sortFolders() } catch { @@ -614,9 +660,16 @@ struct ConversationListView: View { private func deleteFolder(_ folder: Folder) { do { try DatabaseService.shared.deleteFolder(id: folder.id) + // Matches the DB's reparent-up-one-level semantics: children and conversations + // filed directly in this folder move to its own parent (nil if it was top-level), + // not blanket-unfiled. + let parentId = folder.parentId folders.removeAll { $0.id == folder.id } + for i in folders.indices where folders[i].parentId == folder.id { + folders[i].parentId = parentId + } for i in conversations.indices where conversations[i].folderId == folder.id { - conversations[i].folderId = nil + conversations[i].folderId = parentId } } catch { Log.db.error("Failed to delete folder: \(error.localizedDescription)") diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index e95bf6e..5d58eb3 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -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() diff --git a/oAITests/FolderPureLogicTests.swift b/oAITests/FolderPureLogicTests.swift new file mode 100644 index 0000000..6fefd8c --- /dev/null +++ b/oAITests/FolderPureLogicTests.swift @@ -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) + } +}