2.5.0 #10
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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