diff --git a/oAI/Models/Conversation.swift b/oAI/Models/Conversation.swift index 40fc833..cddee0f 100644 --- a/oAI/Models/Conversation.swift +++ b/oAI/Models/Conversation.swift @@ -30,6 +30,7 @@ struct Conversation: Identifiable, Codable { let createdAt: Date var updatedAt: Date var primaryModel: String? // Primary model used in this conversation + var folderId: UUID? // Folder this conversation is filed under, if any nonisolated init( id: UUID = UUID(), @@ -37,7 +38,8 @@ struct Conversation: Identifiable, Codable { messages: [Message] = [], createdAt: Date = Date(), updatedAt: Date = Date(), - primaryModel: String? = nil + primaryModel: String? = nil, + folderId: UUID? = nil ) { self.id = id self.name = name @@ -45,6 +47,7 @@ struct Conversation: Identifiable, Codable { self.createdAt = createdAt self.updatedAt = updatedAt self.primaryModel = primaryModel + self.folderId = folderId } var messageCount: Int { diff --git a/oAI/Models/Folder.swift b/oAI/Models/Folder.swift new file mode 100644 index 0000000..b572d36 --- /dev/null +++ b/oAI/Models/Folder.swift @@ -0,0 +1,43 @@ +// +// Folder.swift +// oAI +// +// Model for grouping saved conversations +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI 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 oAI 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 + +struct Folder: Identifiable, Codable, Sendable { + let id: UUID + var name: String + var sortOrder: Int + let createdAt: Date + + nonisolated init( + id: UUID = UUID(), + name: String, + sortOrder: Int = 0, + createdAt: Date = Date() + ) { + self.id = id + self.name = name + self.sortOrder = sortOrder + self.createdAt = createdAt + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 1d1150b..9fdeee3 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -35,6 +35,16 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable var createdAt: String var updatedAt: String var primaryModel: String? + var folderId: String? +} + +struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "folders" + + var id: String + var name: String + var sortOrder: Int + var createdAt: String } struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { @@ -342,6 +352,20 @@ final class DatabaseService: Sendable { ) } + migrator.registerMigration("v9") { db in + // Folders for organizing conversations + try db.create(table: "folders") { t in + t.primaryKey("id", .text) + t.column("name", .text).notNull() + t.column("sortOrder", .integer).notNull().defaults(to: 0) + t.column("createdAt", .text).notNull() + } + + try db.alter(table: "conversations") { t in + t.add(column: "folderId", .text).references("folders", onDelete: .setNull) + } + } + return migrator } @@ -404,7 +428,7 @@ final class DatabaseService: Sendable { return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil) } - nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation { + nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?, folderId: UUID? = nil) throws -> Conversation { Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))") let now = Date() let nowString = Self.isoString(from: now) @@ -414,7 +438,8 @@ final class DatabaseService: Sendable { name: name, createdAt: nowString, updatedAt: nowString, - primaryModel: primaryModel + primaryModel: primaryModel, + folderId: folderId?.uuidString ) let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in @@ -446,7 +471,8 @@ final class DatabaseService: Sendable { messages: savedMessages, createdAt: now, updatedAt: now, - primaryModel: primaryModel + primaryModel: primaryModel, + folderId: folderId ) } @@ -526,7 +552,8 @@ final class DatabaseService: Sendable { messages: messages, createdAt: createdAt, updatedAt: updatedAt, - primaryModel: convRecord.primaryModel + primaryModel: convRecord.primaryModel, + folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) } ) return (conversation, messages) @@ -568,7 +595,8 @@ final class DatabaseService: Sendable { messages: Array(repeating: Message(role: .user, content: ""), count: messageCount), createdAt: createdAt, updatedAt: lastDate, - primaryModel: primaryModel + primaryModel: primaryModel, + folderId: record.folderId.flatMap { UUID(uuidString: $0) } ) conv.updatedAt = lastDate return conv @@ -576,6 +604,68 @@ final class DatabaseService: Sendable { } } + // MARK: - Folders + + nonisolated func createFolder(name: String) throws -> Folder { + let folder = Folder(name: name, sortOrder: try nextFolderSortOrder()) + let record = FolderRecord( + id: folder.id.uuidString, + name: folder.name, + sortOrder: folder.sortOrder, + createdAt: Self.isoString(from: folder.createdAt) + ) + try dbQueue.write { db in + try record.insert(db) + } + return folder + } + + private nonisolated func nextFolderSortOrder() throws -> Int { + try dbQueue.read { db in + let row = try Row.fetchOne(db, sql: "SELECT MAX(sortOrder) AS maxOrder FROM folders") + let maxOrder: Int? = row?["maxOrder"] + return (maxOrder ?? -1) + 1 + } + } + + nonisolated func renameFolder(id: UUID, name: String) throws { + try dbQueue.write { db in + try db.execute( + sql: "UPDATE folders SET name = ? WHERE id = ?", + arguments: [name, id.uuidString] + ) + } + } + + nonisolated func deleteFolder(id: UUID) throws { + try dbQueue.write { db in + try FolderRecord.deleteOne(db, key: id.uuidString) + } + } + + nonisolated func listFolders() throws -> [Folder] { + try dbQueue.read { db in + let records = try FolderRecord + .order(Column("sortOrder")) + .fetchAll(db) + 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) + } + } + } + + nonisolated func moveConversation(id: UUID, toFolder folderId: UUID?) throws { + try dbQueue.write { db in + try db.execute( + sql: "UPDATE conversations SET folderId = ? WHERE id = ?", + arguments: [folderId?.uuidString, id.uuidString] + ) + } + } + // MARK: - Usage Statistics nonisolated func getOverallUsageStats() throws -> UsageStats { diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index 1233471..a1ce611 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -29,6 +29,7 @@ import AppKit struct SidebarView: View { @Environment(ChatViewModel.self) private var chatViewModel @State private var conversations: [Conversation] = [] + @State private var folders: [Folder] = [] @State private var searchText = "" private var filteredConversations: [Conversation] { @@ -36,6 +37,10 @@ struct SidebarView: View { return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } } + private var conversationsByFolder: [UUID?: [Conversation]] { + Dictionary(grouping: filteredConversations, by: { $0.folderId }) + } + var body: some View { VStack(spacing: 0) { // New Chat button @@ -72,6 +77,15 @@ struct SidebarView: View { .buttonStyle(.plain) } Divider().frame(height: 12) + Button { + createFolderPrompt() + } label: { + Image(systemName: "folder.badge.plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("New Folder") Button { chatViewModel.showConversations = true } label: { @@ -101,45 +115,127 @@ struct SidebarView: View { .foregroundStyle(.secondary) } Spacer() - } else { + } else if folders.isEmpty { List { ForEach(filteredConversations) { conversation in - SidebarConversationRow(conversation: conversation) - .contentShape(Rectangle()) - .onTapGesture { - chatViewModel.loadConversation(conversation) - } - .listRowBackground( - chatViewModel.currentConversationName == conversation.name - ? Color.oaiAccent.opacity(0.15) - : Color.clear - ) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - deleteConversation(conversation) - } label: { - Label("Delete", systemImage: "trash") + conversationRow(conversation) + } + } + .listStyle(.sidebar) + } else { + List { + ForEach(folders) { folder in + let folderConversations = conversationsByFolder[folder.id] ?? [] + if !folderConversations.isEmpty || searchText.isEmpty { + Section { + ForEach(folderConversations) { conversation in + conversationRow(conversation) } - Button { - renameConversation(conversation) - } label: { - Label("Rename", systemImage: "pencil") - } - .tint(.orange) + } header: { + Text(folder.name) + .contextMenu { + Button { + renameFolderPrompt(folder) + } label: { + Label("Rename Folder", systemImage: "pencil") + } + Button(role: .destructive) { + deleteFolder(folder) + } label: { + Label("Delete Folder", systemImage: "trash") + } + } } + } + } + + let unfiled = conversationsByFolder[nil] ?? [] + if !unfiled.isEmpty { + Section("Unfiled") { + ForEach(unfiled) { conversation in + conversationRow(conversation) + } + } } } .listStyle(.sidebar) } } - .onAppear { loadConversations() } - .onChange(of: chatViewModel.currentConversationName) { loadConversations() } - .onChange(of: chatViewModel.messages.count) { loadConversations() } + .onAppear { loadData() } + .onChange(of: chatViewModel.currentConversationName) { loadData() } + .onChange(of: chatViewModel.messages.count) { loadData() } } - private func loadConversations() { + @ViewBuilder + private func conversationRow(_ conversation: Conversation) -> some View { + SidebarConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + chatViewModel.loadConversation(conversation) + } + .listRowBackground( + chatViewModel.currentConversationName == conversation.name + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + } + .contextMenu { + Menu { + if conversation.folderId != nil { + Button { + moveConversation(conversation, toFolder: nil) + } label: { + Label("Remove from Folder", systemImage: "folder.badge.minus") + } + Divider() + } + ForEach(folders) { folder in + if folder.id != conversation.folderId { + Button { + moveConversation(conversation, toFolder: folder.id) + } label: { + Text(folder.name) + } + } + } + Divider() + Button { + createFolderPrompt(andMove: conversation) + } label: { + Label("New Folder…", systemImage: "folder.badge.plus") + } + } label: { + Label("Move to Folder", systemImage: "folder") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + private func loadData() { conversations = (try? DatabaseService.shared.listConversations()) ?? [] + folders = (try? DatabaseService.shared.listFolders()) ?? [] } private func deleteConversation(_ conversation: Conversation) { @@ -175,6 +271,78 @@ struct SidebarView: View { } #endif } + + private func moveConversation(_ conversation: Conversation, toFolder folderId: UUID?) { + do { + try DatabaseService.shared.moveConversation(id: conversation.id, toFolder: folderId) + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].folderId = folderId + } + } catch { + Log.db.error("Failed to move conversation: \(error.localizedDescription)") + } + } + + private func createFolderPrompt(andMove conversation: Conversation? = nil) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "New Folder" + alert.addButton(withTitle: "Create") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let name = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + do { + let folder = try DatabaseService.shared.createFolder(name: name) + folders.append(folder) + if let conversation = conversation { + moveConversation(conversation, toFolder: folder.id) + } + } catch { + Log.db.error("Failed to create folder: \(error.localizedDescription)") + } + #endif + } + + private func renameFolderPrompt(_ folder: Folder) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Folder" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = folder.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != folder.name else { return } + do { + try DatabaseService.shared.renameFolder(id: folder.id, name: newName) + if let i = folders.firstIndex(where: { $0.id == folder.id }) { + folders[i].name = newName + } + } catch { + Log.db.error("Failed to rename folder: \(error.localizedDescription)") + } + #endif + } + + private func deleteFolder(_ folder: Folder) { + do { + try DatabaseService.shared.deleteFolder(id: folder.id) + folders.removeAll { $0.id == folder.id } + for i in conversations.indices where conversations[i].folderId == folder.id { + conversations[i].folderId = nil + } + } catch { + Log.db.error("Failed to delete folder: \(error.localizedDescription)") + } + } } // MARK: - Sidebar conversation row diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 94c3072..c297bf8 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -28,6 +28,7 @@ struct ConversationListView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var conversations: [Conversation] = [] + @State private var folders: [Folder] = [] @State private var selectedConversations: Set = [] @State private var isSelecting = false @State private var useSemanticSearch = false @@ -54,6 +55,10 @@ struct ConversationListView: View { } } + private var conversationsByFolder: [UUID?: [Conversation]] { + Dictionary(grouping: filteredConversations, by: { $0.folderId }) + } + var body: some View { VStack(spacing: 0) { // Header @@ -200,81 +205,43 @@ struct ConversationListView: View { } else { ScrollViewReader { proxy in List { - ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in - HStack(spacing: 12) { - if isSelecting { - Button { - toggleSelection(conversation.id) - } label: { - Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle") - .foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary) - .font(.title2) - } - .buttonStyle(.plain) - } - - ConversationRow(conversation: conversation) - .contentShape(Rectangle()) - .onTapGesture { - if isSelecting { - toggleSelection(conversation.id) - } else { - selectedIndex = index - onLoad?(conversation) - dismiss() + if folders.isEmpty { + ForEach(filteredConversations) { conversation in + conversationRow(conversation) + } + } else { + ForEach(folders) { folder in + let folderConversations = conversationsByFolder[folder.id] ?? [] + if !folderConversations.isEmpty || searchText.isEmpty { + Section { + ForEach(folderConversations) { conversation in + conversationRow(conversation) } + } header: { + Text(folder.name) + .contextMenu { + Button { + renameFolderPrompt(folder) + } label: { + Label("Rename Folder", systemImage: "pencil") + } + Button(role: .destructive) { + deleteFolder(folder) + } label: { + Label("Delete Folder", systemImage: "trash") + } + } } - - Spacer() - - if !isSelecting { - Button { - renameConversation(conversation) - } label: { - Image(systemName: "pencil") - .foregroundStyle(.secondary) - .font(.system(size: 15)) - } - .buttonStyle(.plain) - .help("Rename conversation") - - Button { - deleteConversation(conversation) - } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - .font(.system(size: 16)) - } - .buttonStyle(.plain) - .help("Delete conversation") } } - .listRowBackground( - !isSelecting && index == selectedIndex - ? Color.oaiAccent.opacity(0.15) - : Color.clear - ) - .id(conversation.id) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - deleteConversation(conversation) - } label: { - Label("Delete", systemImage: "trash") - } - Button { - renameConversation(conversation) - } label: { - Label("Rename", systemImage: "pencil") + let unfiled = conversationsByFolder[nil] ?? [] + if !unfiled.isEmpty { + Section("Unfiled") { + ForEach(unfiled) { conversation in + conversationRow(conversation) + } } - .tint(.orange) - - Button { - exportConversation(conversation) - } label: { - Label("Export", systemImage: "square.and.arrow.up") - } - .tint(.blue) } } } @@ -321,15 +288,177 @@ struct ConversationListView: View { } } + @ViewBuilder + private func conversationRow(_ conversation: Conversation) -> some View { + let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0 + HStack(spacing: 12) { + if isSelecting { + Button { + toggleSelection(conversation.id) + } label: { + Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary) + .font(.title2) + } + .buttonStyle(.plain) + } + + ConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + if isSelecting { + toggleSelection(conversation.id) + } else { + selectedIndex = index + onLoad?(conversation) + dismiss() + } + } + + Spacer() + + if !isSelecting { + Button { + renameConversation(conversation) + } label: { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .font(.system(size: 15)) + } + .buttonStyle(.plain) + .help("Rename conversation") + + Button { + deleteConversation(conversation) + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + .font(.system(size: 16)) + } + .buttonStyle(.plain) + .help("Delete conversation") + } + } + .listRowBackground( + !isSelecting && index == selectedIndex + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .id(conversation.id) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + + Button { + exportConversation(conversation) + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .tint(.blue) + } + .contextMenu { + Menu { + if conversation.folderId != nil { + Button { + moveConversation(conversation, toFolder: nil) + } label: { + Label("Remove from Folder", systemImage: "folder.badge.minus") + } + Divider() + } + ForEach(folders) { folder in + if folder.id != conversation.folderId { + Button { + moveConversation(conversation, toFolder: folder.id) + } label: { + Text(folder.name) + } + } + } + } label: { + Label("Move to Folder", systemImage: "folder") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + private func loadConversations() { do { conversations = try DatabaseService.shared.listConversations() + folders = try DatabaseService.shared.listFolders() } catch { Log.db.error("Failed to load conversations: \(error.localizedDescription)") conversations = [] } } + private func moveConversation(_ conversation: Conversation, toFolder folderId: UUID?) { + do { + try DatabaseService.shared.moveConversation(id: conversation.id, toFolder: folderId) + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].folderId = folderId + } + } catch { + Log.db.error("Failed to move conversation: \(error.localizedDescription)") + } + } + + private func renameFolderPrompt(_ folder: Folder) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Folder" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = folder.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != folder.name else { return } + do { + try DatabaseService.shared.renameFolder(id: folder.id, name: newName) + if let i = folders.firstIndex(where: { $0.id == folder.id }) { + folders[i].name = newName + } + } catch { + Log.db.error("Failed to rename folder: \(error.localizedDescription)") + } + #endif + } + + private func deleteFolder(_ folder: Folder) { + do { + try DatabaseService.shared.deleteFolder(id: folder.id) + folders.removeAll { $0.id == folder.id } + for i in conversations.indices where conversations[i].folderId == folder.id { + conversations[i].folderId = nil + } + } catch { + Log.db.error("Failed to delete folder: \(error.localizedDescription)") + } + } + private func toggleSelection(_ id: UUID) { if selectedConversations.contains(id) { selectedConversations.remove(id) diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index d9835cf..1a22d65 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -220,3 +220,96 @@ struct DatabaseServiceUsageStatsTests { #expect(byConversation.count == 3) } } + +@Suite("DatabaseService folders, against a throwaway in-memory queue") +struct DatabaseServiceFolderTests { + + @Test("v9 adds the folders table and folderId to conversations") + func v9AddsFolderSupport() { + let db = DatabaseService.makeInMemory() + #expect(db.tableExists("folders")) + #expect(db.columnNames(in: "conversations").contains("folderId")) + } + + @Test("Creating folders assigns increasing sort order") + func createFolderAssignsSortOrder() throws { + let db = DatabaseService.makeInMemory() + let first = try db.createFolder(name: "Work") + let second = try db.createFolder(name: "Personal") + #expect(first.sortOrder == 0) + #expect(second.sortOrder == 1) + } + + @Test("listFolders returns folders ordered by sortOrder") + func listFoldersOrdered() throws { + let db = DatabaseService.makeInMemory() + _ = try db.createFolder(name: "Work") + _ = try db.createFolder(name: "Personal") + + let folders = try db.listFolders() + #expect(folders.map(\.name) == ["Work", "Personal"]) + } + + @Test("renameFolder updates the stored name") + func renameFolderUpdatesName() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + try db.renameFolder(id: folder.id, name: "Projects") + + let folders = try db.listFolders() + #expect(folders.first?.name == "Projects") + } + + @Test("moveConversation files a conversation into a folder") + func moveConversationFiles() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")]) + + try db.moveConversation(id: conversation.id, toFolder: folder.id) + + let loaded = try db.loadConversation(id: conversation.id) + #expect(loaded?.0.folderId == folder.id) + } + + @Test("moveConversation with nil folder unfiles a conversation") + func moveConversationUnfiles() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")]) + try db.moveConversation(id: conversation.id, toFolder: folder.id) + + try db.moveConversation(id: conversation.id, toFolder: nil) + + let loaded = try db.loadConversation(id: conversation.id) + #expect(loaded?.0.folderId == nil) + } + + @Test("Deleting a folder unfiles its conversations instead of deleting them") + func deleteFolderUnfilesConversations() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")]) + try db.moveConversation(id: conversation.id, toFolder: folder.id) + + try db.deleteFolder(id: folder.id) + + let folders = try db.listFolders() + #expect(folders.isEmpty) + + let loaded = try db.loadConversation(id: conversation.id) + #expect(loaded != nil) + #expect(loaded?.0.folderId == nil) + } + + @Test("listConversations reflects folderId") + func listConversationsReflectsFolderId() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")]) + try db.moveConversation(id: conversation.id, toFolder: folder.id) + + let conversations = try db.listConversations() + #expect(conversations.first?.folderId == folder.id) + } +}