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:
@@ -30,6 +30,7 @@ struct Conversation: Identifiable, Codable {
|
|||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var primaryModel: String? // Primary model used in this conversation
|
var primaryModel: String? // Primary model used in this conversation
|
||||||
|
var folderId: UUID? // Folder this conversation is filed under, if any
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -37,7 +38,8 @@ struct Conversation: Identifiable, Codable {
|
|||||||
messages: [Message] = [],
|
messages: [Message] = [],
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
updatedAt: Date = Date(),
|
updatedAt: Date = Date(),
|
||||||
primaryModel: String? = nil
|
primaryModel: String? = nil,
|
||||||
|
folderId: UUID? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -45,6 +47,7 @@ struct Conversation: Identifiable, Codable {
|
|||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.primaryModel = primaryModel
|
self.primaryModel = primaryModel
|
||||||
|
self.folderId = folderId
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageCount: Int {
|
var messageCount: Int {
|
||||||
|
|||||||
@@ -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
|
||||||
|
// <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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,16 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
|
|||||||
var createdAt: String
|
var createdAt: String
|
||||||
var updatedAt: String
|
var updatedAt: String
|
||||||
var primaryModel: 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 {
|
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
|
return migrator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +428,7 @@ final class DatabaseService: Sendable {
|
|||||||
return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
|
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"))")
|
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let nowString = Self.isoString(from: now)
|
let nowString = Self.isoString(from: now)
|
||||||
@@ -414,7 +438,8 @@ final class DatabaseService: Sendable {
|
|||||||
name: name,
|
name: name,
|
||||||
createdAt: nowString,
|
createdAt: nowString,
|
||||||
updatedAt: nowString,
|
updatedAt: nowString,
|
||||||
primaryModel: primaryModel
|
primaryModel: primaryModel,
|
||||||
|
folderId: folderId?.uuidString
|
||||||
)
|
)
|
||||||
|
|
||||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||||
@@ -446,7 +471,8 @@ final class DatabaseService: Sendable {
|
|||||||
messages: savedMessages,
|
messages: savedMessages,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
primaryModel: primaryModel
|
primaryModel: primaryModel,
|
||||||
|
folderId: folderId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +552,8 @@ final class DatabaseService: Sendable {
|
|||||||
messages: messages,
|
messages: messages,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
primaryModel: convRecord.primaryModel
|
primaryModel: convRecord.primaryModel,
|
||||||
|
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
return (conversation, messages)
|
return (conversation, messages)
|
||||||
@@ -568,7 +595,8 @@ final class DatabaseService: Sendable {
|
|||||||
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
|
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: lastDate,
|
updatedAt: lastDate,
|
||||||
primaryModel: primaryModel
|
primaryModel: primaryModel,
|
||||||
|
folderId: record.folderId.flatMap { UUID(uuidString: $0) }
|
||||||
)
|
)
|
||||||
conv.updatedAt = lastDate
|
conv.updatedAt = lastDate
|
||||||
return conv
|
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
|
// MARK: - Usage Statistics
|
||||||
|
|
||||||
nonisolated func getOverallUsageStats() throws -> UsageStats {
|
nonisolated func getOverallUsageStats() throws -> UsageStats {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import AppKit
|
|||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Environment(ChatViewModel.self) private var chatViewModel
|
@Environment(ChatViewModel.self) private var chatViewModel
|
||||||
@State private var conversations: [Conversation] = []
|
@State private var conversations: [Conversation] = []
|
||||||
|
@State private var folders: [Folder] = []
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
|
||||||
private var filteredConversations: [Conversation] {
|
private var filteredConversations: [Conversation] {
|
||||||
@@ -36,6 +37,10 @@ struct SidebarView: View {
|
|||||||
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
|
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var conversationsByFolder: [UUID?: [Conversation]] {
|
||||||
|
Dictionary(grouping: filteredConversations, by: { $0.folderId })
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// New Chat button
|
// New Chat button
|
||||||
@@ -72,6 +77,15 @@ struct SidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
Divider().frame(height: 12)
|
Divider().frame(height: 12)
|
||||||
|
Button {
|
||||||
|
createFolderPrompt()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("New Folder")
|
||||||
Button {
|
Button {
|
||||||
chatViewModel.showConversations = true
|
chatViewModel.showConversations = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -101,45 +115,127 @@ struct SidebarView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} else if folders.isEmpty {
|
||||||
List {
|
List {
|
||||||
ForEach(filteredConversations) { conversation in
|
ForEach(filteredConversations) { conversation in
|
||||||
SidebarConversationRow(conversation: conversation)
|
conversationRow(conversation)
|
||||||
.contentShape(Rectangle())
|
}
|
||||||
.onTapGesture {
|
}
|
||||||
chatViewModel.loadConversation(conversation)
|
.listStyle(.sidebar)
|
||||||
}
|
} else {
|
||||||
.listRowBackground(
|
List {
|
||||||
chatViewModel.currentConversationName == conversation.name
|
ForEach(folders) { folder in
|
||||||
? Color.oaiAccent.opacity(0.15)
|
let folderConversations = conversationsByFolder[folder.id] ?? []
|
||||||
: Color.clear
|
if !folderConversations.isEmpty || searchText.isEmpty {
|
||||||
)
|
Section {
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
ForEach(folderConversations) { conversation in
|
||||||
Button(role: .destructive) {
|
conversationRow(conversation)
|
||||||
deleteConversation(conversation)
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
}
|
||||||
Button {
|
} header: {
|
||||||
renameConversation(conversation)
|
Text(folder.name)
|
||||||
} label: {
|
.contextMenu {
|
||||||
Label("Rename", systemImage: "pencil")
|
Button {
|
||||||
}
|
renameFolderPrompt(folder)
|
||||||
.tint(.orange)
|
} 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)
|
.listStyle(.sidebar)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.onAppear { loadConversations() }
|
.onAppear { loadData() }
|
||||||
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
|
.onChange(of: chatViewModel.currentConversationName) { loadData() }
|
||||||
.onChange(of: chatViewModel.messages.count) { loadConversations() }
|
.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()) ?? []
|
conversations = (try? DatabaseService.shared.listConversations()) ?? []
|
||||||
|
folders = (try? DatabaseService.shared.listFolders()) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteConversation(_ conversation: Conversation) {
|
private func deleteConversation(_ conversation: Conversation) {
|
||||||
@@ -175,6 +271,78 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
// MARK: - Sidebar conversation row
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct ConversationListView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var conversations: [Conversation] = []
|
@State private var conversations: [Conversation] = []
|
||||||
|
@State private var folders: [Folder] = []
|
||||||
@State private var selectedConversations: Set<UUID> = []
|
@State private var selectedConversations: Set<UUID> = []
|
||||||
@State private var isSelecting = false
|
@State private var isSelecting = false
|
||||||
@State private var useSemanticSearch = 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
@@ -200,81 +205,43 @@ struct ConversationListView: View {
|
|||||||
} else {
|
} else {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
List {
|
List {
|
||||||
ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in
|
if folders.isEmpty {
|
||||||
HStack(spacing: 12) {
|
ForEach(filteredConversations) { conversation in
|
||||||
if isSelecting {
|
conversationRow(conversation)
|
||||||
Button {
|
}
|
||||||
toggleSelection(conversation.id)
|
} else {
|
||||||
} label: {
|
ForEach(folders) { folder in
|
||||||
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
|
let folderConversations = conversationsByFolder[folder.id] ?? []
|
||||||
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
|
if !folderConversations.isEmpty || searchText.isEmpty {
|
||||||
.font(.title2)
|
Section {
|
||||||
}
|
ForEach(folderConversations) { conversation in
|
||||||
.buttonStyle(.plain)
|
conversationRow(conversation)
|
||||||
}
|
|
||||||
|
|
||||||
ConversationRow(conversation: conversation)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
if isSelecting {
|
|
||||||
toggleSelection(conversation.id)
|
|
||||||
} else {
|
|
||||||
selectedIndex = index
|
|
||||||
onLoad?(conversation)
|
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
|
} 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 {
|
let unfiled = conversationsByFolder[nil] ?? []
|
||||||
renameConversation(conversation)
|
if !unfiled.isEmpty {
|
||||||
} label: {
|
Section("Unfiled") {
|
||||||
Label("Rename", systemImage: "pencil")
|
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() {
|
private func loadConversations() {
|
||||||
do {
|
do {
|
||||||
conversations = try DatabaseService.shared.listConversations()
|
conversations = try DatabaseService.shared.listConversations()
|
||||||
|
folders = try DatabaseService.shared.listFolders()
|
||||||
} catch {
|
} catch {
|
||||||
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
|
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
|
||||||
conversations = []
|
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) {
|
private func toggleSelection(_ id: UUID) {
|
||||||
if selectedConversations.contains(id) {
|
if selectedConversations.contains(id) {
|
||||||
selectedConversations.remove(id)
|
selectedConversations.remove(id)
|
||||||
|
|||||||
@@ -220,3 +220,96 @@ struct DatabaseServiceUsageStatsTests {
|
|||||||
#expect(byConversation.count == 3)
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user