Add folders for organizing saved conversations

Conversations can now be filed into flat (non-nested) folders, shown
as collapsible sections in both the sidebar and the advanced
conversation list. New folders migration (v9) adds a folders table
and conversations.folderId with ON DELETE SET NULL, so deleting a
folder unfiles its conversations rather than losing them. Move/rename/
delete via context menu; conversation lists with no folders fall back
to the existing flat view unchanged.
This commit is contained in:
2026-07-28 12:43:05 +02:00
parent 0642f3746f
commit 376fff4939
6 changed files with 627 additions and 101 deletions
+93
View File
@@ -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)
}
}