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
+51
View File
@@ -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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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
}
}
}
+55 -1
View File
@@ -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<UUID>) {
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<UUID> = []
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<UUID>) -> Set<UUID> {
var visible: Set<UUID> = []
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
}
}
+60 -9
View File
@@ -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(
+7 -5
View File
@@ -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
+76 -46
View File
@@ -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<UUID> { 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)")
+91 -38
View File
@@ -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<UUID> { 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)")