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
+95 -5
View File
@@ -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 {