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
+4 -1
View File
@@ -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 {
+43
View File
@@ -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
}
}
+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 {
+194 -26
View File
@@ -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
+198 -69
View File
@@ -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,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)