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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user