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
|
||||
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 {
|
||||
|
||||
@@ -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 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 {
|
||||
|
||||
@@ -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,9 +115,60 @@ struct SidebarView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
} else if folders.isEmpty {
|
||||
List {
|
||||
ForEach(filteredConversations) { conversation in
|
||||
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)
|
||||
}
|
||||
} 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 { loadData() }
|
||||
.onChange(of: chatViewModel.currentConversationName) { loadData() }
|
||||
.onChange(of: chatViewModel.messages.count) { loadData() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
||||
SidebarConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@@ -127,19 +192,50 @@ struct SidebarView: View {
|
||||
}
|
||||
.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")
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
}
|
||||
.onAppear { loadConversations() }
|
||||
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
|
||||
.onChange(of: chatViewModel.messages.count) { loadConversations() }
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
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
|
||||
|
||||
@@ -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<UUID> = []
|
||||
@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,7 +205,92 @@ struct ConversationListView: View {
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unfiled = conversationsByFolder[nil] ?? []
|
||||
if !unfiled.isEmpty {
|
||||
Section("Unfiled") {
|
||||
ForEach(unfiled) { conversation in
|
||||
conversationRow(conversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: selectedIndex) {
|
||||
guard !filteredConversations.isEmpty else { return }
|
||||
let clamped = min(selectedIndex, filteredConversations.count - 1)
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
proxy.scrollTo(filteredConversations[clamped].id, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Text("↑↓ navigate ↩ open")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
searchFocused = true
|
||||
}
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||
.sheet(isPresented: $showCombineSheet) {
|
||||
CombineConversationsSheet(
|
||||
conversations: conversations.filter { selectedConversations.contains($0.id) },
|
||||
onCompleted: { _ in
|
||||
loadConversations()
|
||||
selectedConversations.removeAll()
|
||||
isSelecting = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
||||
let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0
|
||||
HStack(spacing: 12) {
|
||||
if isSelecting {
|
||||
Button {
|
||||
@@ -276,60 +366,99 @@ struct ConversationListView: View {
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
.contextMenu {
|
||||
Menu {
|
||||
if conversation.folderId != nil {
|
||||
Button {
|
||||
moveConversation(conversation, toFolder: nil)
|
||||
} label: {
|
||||
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: selectedIndex) {
|
||||
guard !filteredConversations.isEmpty else { return }
|
||||
let clamped = min(selectedIndex, filteredConversations.count - 1)
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
proxy.scrollTo(filteredConversations[clamped].id, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Text("↑↓ navigate ↩ open")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
ForEach(folders) { folder in
|
||||
if folder.id != conversation.folderId {
|
||||
Button {
|
||||
moveConversation(conversation, toFolder: folder.id)
|
||||
} label: {
|
||||
Text(folder.name)
|
||||
}
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
searchFocused = true
|
||||
}
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||
.sheet(isPresented: $showCombineSheet) {
|
||||
CombineConversationsSheet(
|
||||
conversations: conversations.filter { selectedConversations.contains($0.id) },
|
||||
onCompleted: { _ in
|
||||
loadConversations()
|
||||
selectedConversations.removeAll()
|
||||
isSelecting = false
|
||||
}
|
||||
)
|
||||
} 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user