// // ConversationListView.swift // Confab // // Saved conversations list // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab 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 Confab 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . import os import SwiftUI struct ConversationListView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var conversations: [Conversation] = [] @State private var folders: [Folder] = [] @State private var collapsedFolders: Set = [] @State private var selectedConversations: Set = [] @State private var isSelecting = false @State private var lastClickedId: UUID? = nil @State private var useSemanticSearch = false @State private var semanticResults: [Conversation] = [] @State private var isSearching = false @State private var selectedIndex: Int = 0 @State private var showCombineSheet = false @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? var onRename: ((UUID, String) -> Void)? private var filteredConversations: [Conversation] { if searchText.isEmpty { return conversations } if useSemanticSearch && settings.embeddingsEnabled { return semanticResults } else { return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } } } private var conversationsByFolder: [UUID?: [Conversation]] { Dictionary(grouping: filteredConversations, by: { $0.folderId }) } private var orderedFolderTree: [(folder: Folder, depth: Int)] { Folder.orderedTree(from: folders) } private var visibleFolderIds: Set { Folder.visibleFolderIds(tree: orderedFolderTree, collapsed: collapsedFolders) } /// Flattened conversation order matching what's actually rendered in the List — folders in /// depth-first tree order (skipping collapsed ones' contents, since they're not /// visible/selectable), then Unfiled last. Used as the anchor sequence for Shift-click range /// selection. private var visibleOrderedConversations: [Conversation] { guard !folders.isEmpty else { return filteredConversations } var result: [Conversation] = [] for (folder, _) in orderedFolderTree where visibleFolderIds.contains(folder.id) && !collapsedFolders.contains(folder.id) { result.append(contentsOf: conversationsByFolder[folder.id] ?? []) } result.append(contentsOf: conversationsByFolder[nil] ?? []) return result } var body: some View { VStack(spacing: 0) { // Header HStack { Text("Conversations") .font(.system(size: 18, weight: .bold)) Spacer() if isSelecting { Button("Cancel") { isSelecting = false selectedConversations.removeAll() lastClickedId = nil } .buttonStyle(.plain) if selectedConversations.count >= 2 { Button { showCombineSheet = true } label: { HStack(spacing: 4) { Image(systemName: "arrow.triangle.merge") Text("Combine (\(selectedConversations.count))") } } .buttonStyle(.plain) } if !selectedConversations.isEmpty { Menu { if !folders.isEmpty { ForEach(orderedFolderTree, id: \.folder.id) { entry in Button(String(repeating: " ", count: entry.depth) + entry.folder.name) { moveSelectedToFolder(entry.folder.id) } } Divider() } Button("Remove from Folder") { moveSelectedToFolder(nil) } } label: { HStack(spacing: 4) { Image(systemName: "folder") Text("Move to Folder (\(selectedConversations.count))") } } .buttonStyle(.plain) } if !selectedConversations.isEmpty { Button(role: .destructive) { deleteSelected() } label: { HStack(spacing: 4) { Image(systemName: "trash") Text("Delete (\(selectedConversations.count))") } } .buttonStyle(.plain) .foregroundStyle(.red) } } else { Button { createFolderPrompt() } label: { Label("New Folder", systemImage: "folder.badge.plus") } .buttonStyle(.plain) if !conversations.isEmpty { Button("Select") { isSelecting = true } .buttonStyle(.plain) } Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundStyle(.secondary) } .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) } } .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 12) // Search bar HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField("Search conversations...", text: $searchText) .textFieldStyle(.plain) .focused($searchFocused) .onChange(of: searchText) { selectedIndex = 0 if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty { performSemanticSearch() } } #if os(macOS) .onKeyPress(.upArrow) { if selectedIndex > 0 { selectedIndex -= 1 } return .handled } .onKeyPress(.downArrow) { if selectedIndex < filteredConversations.count - 1 { selectedIndex += 1 } return .handled } .onKeyPress(.return, phases: .down) { _ in guard !isSelecting, !filteredConversations.isEmpty else { return .ignored } let conv = filteredConversations[min(selectedIndex, filteredConversations.count - 1)] onLoad?(conv) dismiss() return .handled } #endif if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) } if settings.embeddingsEnabled { Divider() .frame(height: 16) Toggle("Semantic", isOn: $useSemanticSearch) .toggleStyle(.switch) .controlSize(.small) .onChange(of: useSemanticSearch) { if useSemanticSearch && !searchText.isEmpty { performSemanticSearch() } } .help("Use AI-powered semantic search instead of keyword matching") } if isSearching { ProgressView() .controlSize(.small) } } .padding(10) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 24) .padding(.bottom, 12) Divider() // Content if filteredConversations.isEmpty { Spacer() VStack(spacing: 8) { Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") .font(.largeTitle) .foregroundStyle(.tertiary) Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") .font(.headline) .foregroundStyle(.secondary) Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term") .font(.caption) .foregroundStyle(.tertiary) } Spacer() } else { ScrollViewReader { proxy in List { if folders.isEmpty { ForEach(filteredConversations) { conversation in conversationRow(conversation) } } else { ForEach(orderedFolderTree, id: \.folder.id) { entry in if visibleFolderIds.contains(entry.folder.id) { let folderConversations = conversationsByFolder[entry.folder.id] ?? [] if !folderConversations.isEmpty || searchText.isEmpty { Section { if !collapsedFolders.contains(entry.folder.id) { ForEach(folderConversations) { conversation in conversationRow(conversation, depth: entry.depth + 1) } } } header: { folderHeader(entry.folder, depth: entry.depth) } } } } let unfiled = conversationsByFolder[nil] ?? [] if !unfiled.isEmpty { Section { ForEach(unfiled) { conversation in conversationRow(conversation) } } header: { Text("Unfiled") .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: nil) } } } } } .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 collapsedFolders = SettingsService.shared.collapsedFolderIds } .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 lastClickedId = nil } ) } } @ViewBuilder private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View { let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0 HStack(spacing: 12) { if isSelecting { Button { toggleSelection(conversation.id) lastClickedId = 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 { handleRowTap(conversation, index: index) } 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") } } .padding(.leading, CGFloat(depth) * 14) .listRowBackground( !isSelecting && index == selectedIndex ? Color.confabAccent.opacity(0.15) : Color.clear ) .id(conversation.id) .draggable(DraggedItem.conversations( isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 ? Array(selectedConversations) : [conversation.id] ).rawValue) { if isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { Text("\(selectedConversations.count) conversations") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) } else { Text(conversation.name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) } } .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 { moveConversationOrSelection(conversation, toFolder: nil) } label: { Label("Remove from Folder", systemImage: "folder.badge.minus") } Divider() } ForEach(orderedFolderTree, id: \.folder.id) { entry in if entry.folder.id != conversation.folderId { Button { moveConversationOrSelection(conversation, toFolder: entry.folder.id) } label: { Text(String(repeating: " ", count: entry.depth) + entry.folder.name) } } } } label: { Label(isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 ? "Move \(selectedConversations.count) to Folder" : "Move to Folder", systemImage: "folder") } Menu { Button { exportConversation(conversation, format: "md") } label: { Label("Markdown", systemImage: "doc.text") } Button { exportConversation(conversation, format: "html") } label: { Label("HTML", systemImage: "chevron.left.forwardslash.chevron.right") } Button { exportConversation(conversation, format: "pdf") } label: { Label("PDF", systemImage: "doc.richtext") } } label: { Label("Export", systemImage: "square.and.arrow.up") } Button { renameConversation(conversation) } label: { Label("Rename", systemImage: "pencil") } Button(role: .destructive) { deleteConversation(conversation) } label: { Label("Delete", systemImage: "trash") } } } @ViewBuilder private func folderHeader(_ folder: Folder, depth: Int) -> some View { HStack(spacing: 4) { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) .rotationEffect(.degrees(collapsedFolders.contains(folder.id) ? 0 : 90)) Text(folder.name) .font(.system(size: 12, weight: .bold)) } .padding(.leading, CGFloat(depth) * 14) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { toggleCollapsed(folder.id) } } .contextMenu { Button { createFolderPrompt(parentId: folder.id) } label: { Label("New Subfolder…", systemImage: "folder.badge.plus") } Button { renameFolderPrompt(folder) } label: { Label("Rename Folder", systemImage: "pencil") } Button(role: .destructive) { deleteFolder(folder) } label: { Label("Delete Folder", systemImage: "trash") } } .draggable(DraggedItem.folder(folder.id).rawValue) { Text(folder.name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) } .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: folder.id) } } private func toggleCollapsed(_ folderId: UUID) { if collapsedFolders.contains(folderId) { collapsedFolders.remove(folderId) } else { collapsedFolders.insert(folderId) } SettingsService.shared.collapsedFolderIds = collapsedFolders } private func handleDrop(_ items: [String], toFolder targetFolderId: UUID?) -> Bool { var moved = false for raw in items { guard let item = DraggedItem(rawValue: raw) else { continue } switch item { case .conversations(let ids): for id in ids { guard let conversation = conversations.first(where: { $0.id == id }) else { continue } moveConversation(conversation, toFolder: targetFolderId) moved = true } case .folder(let sourceId): guard sourceId != targetFolderId else { continue } if let targetFolderId, Folder.isDescendant(targetFolderId, of: sourceId, in: folders) { continue } do { try DatabaseService.shared.moveFolder(id: sourceId, toParent: targetFolderId) if let i = folders.firstIndex(where: { $0.id == sourceId }) { folders[i].parentId = targetFolderId } moved = true } catch { Log.db.error("Failed to move folder: \(error.localizedDescription)") } } } return moved } private func createFolderPrompt(parentId: UUID? = nil) { #if os(macOS) let alert = NSAlert() alert.messageText = parentId == nil ? "New Folder" : "New Subfolder" 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, parentId: parentId) folders.append(folder) sortFolders() } catch { Log.db.error("Failed to create folder: \(error.localizedDescription)") } #endif } private func sortFolders() { folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } 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)") } } /// Moves every currently-selected conversation to a folder (or removes them all from their /// folders if `folderId` is nil). private func moveSelectedToFolder(_ folderId: UUID?) { for id in selectedConversations { guard let conversation = conversations.first(where: { $0.id == id }) else { continue } moveConversation(conversation, toFolder: folderId) } } /// Right-clicking a conversation that's part of a multi-item selection moves the whole /// selection; right-clicking a single (non-selected, or lone-selected) row moves just that one. private func moveConversationOrSelection(_ conversation: Conversation, toFolder folderId: UUID?) { if isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { moveSelectedToFolder(folderId) } else { moveConversation(conversation, toFolder: folderId) } } 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 } sortFolders() } catch { Log.db.error("Failed to rename folder: \(error.localizedDescription)") } #endif } private func deleteFolder(_ folder: Folder) { do { try DatabaseService.shared.deleteFolder(id: folder.id) // Matches the DB's reparent-up-one-level semantics: children and conversations // filed directly in this folder move to its own parent (nil if it was top-level), // not blanket-unfiled. let parentId = folder.parentId folders.removeAll { $0.id == folder.id } for i in folders.indices where folders[i].parentId == folder.id { folders[i].parentId = parentId } for i in conversations.indices where conversations[i].folderId == folder.id { conversations[i].folderId = parentId } } catch { Log.db.error("Failed to delete folder: \(error.localizedDescription)") } } private func toggleSelection(_ id: UUID) { if selectedConversations.contains(id) { selectedConversations.remove(id) } else { selectedConversations.insert(id) } } /// Standard macOS row-click handling: ⌘-click toggles the individual row (entering selection /// mode if needed), Shift-click extends/creates a contiguous range from the last-clicked row, /// and a plain click either toggles (while already selecting) or opens the conversation. private func handleRowTap(_ conversation: Conversation, index: Int) { #if os(macOS) let modifiers = NSEvent.modifierFlags if modifiers.contains(.command) { isSelecting = true toggleSelection(conversation.id) lastClickedId = conversation.id return } if modifiers.contains(.shift) { isSelecting = true selectRange(to: conversation.id) lastClickedId = conversation.id return } #endif if isSelecting { toggleSelection(conversation.id) lastClickedId = conversation.id } else { selectedIndex = index onLoad?(conversation) dismiss() } } /// Pure range-selection logic, pulled out so it's testable without a live View: given the /// on-screen id order, an anchor, and a target, returns the ids that should end up selected. /// Falls back to just `targetId` if the anchor is nil or no longer present in `orderedIds` /// (e.g. the very first Shift-click, or the anchor row was deleted/filtered out since). nonisolated static func idsInRange(orderedIds: [UUID], anchorId: UUID?, targetId: UUID) -> Set { guard let anchorId, let anchorIndex = orderedIds.firstIndex(of: anchorId), let targetIndex = orderedIds.firstIndex(of: targetId) else { return [targetId] } let range = anchorIndex <= targetIndex ? anchorIndex...targetIndex : targetIndex...anchorIndex return Set(orderedIds[range]) } /// Selects every conversation between `lastClickedId` and `targetId` in on-screen order. private func selectRange(to targetId: UUID) { let orderedIds = visibleOrderedConversations.map { $0.id } selectedConversations.formUnion(Self.idsInRange(orderedIds: orderedIds, anchorId: lastClickedId, targetId: targetId)) } private func deleteSelected() { for id in selectedConversations { do { let _ = try DatabaseService.shared.deleteConversation(id: id) } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } } withAnimation { conversations.removeAll { selectedConversations.contains($0.id) } selectedConversations.removeAll() isSelecting = false } lastClickedId = nil selectedIndex = 0 GitSyncService.shared.syncAfterDeletion() } private func renameConversation(_ conversation: Conversation) { #if os(macOS) let alert = NSAlert() alert.messageText = "Rename Conversation" alert.addButton(withTitle: "Rename") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) input.stringValue = conversation.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 != conversation.name else { return } do { _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil) withAnimation { if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[i].name = newName conversations[i].updatedAt = Date() } } onRename?(conversation.id, newName) } catch { Log.db.error("Failed to rename conversation: \(error.localizedDescription)") } #endif } private func deleteConversation(_ conversation: Conversation) { do { let _ = try DatabaseService.shared.deleteConversation(id: conversation.id) withAnimation { conversations.removeAll { $0.id == conversation.id } } selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1)) GitSyncService.shared.syncAfterDeletion() } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } } private func performSemanticSearch() { guard !searchText.isEmpty else { semanticResults = [] return } isSearching = true Task { do { guard let provider = EmbeddingService.shared.getSelectedProvider() else { Log.api.warning("No embedding providers available - skipping semantic search") await MainActor.run { isSearching = false } return } let embedding = try await EmbeddingService.shared.generateEmbedding( text: searchText, provider: provider ) let results = try DatabaseService.shared.searchConversationsBySemantic( queryEmbedding: embedding, limit: 20 ) await MainActor.run { semanticResults = results.map { $0.0 } selectedIndex = 0 isSearching = false Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)") } } catch { await MainActor.run { semanticResults = [] isSearching = false Log.ui.error("Semantic search failed: \(error)") } } } } private func exportConversation(_ conversation: Conversation, format: String = "md") { guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id), !loadedMessages.isEmpty else { return } let baseName = conversation.name.replacingOccurrences(of: " ", with: "_") if format == "pdf" { Task { @MainActor in guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else { return } _ = ConversationExportService.writeToDownloads(data, filename: baseName + ".pdf") } return } let content: String let filename: String switch format { case "html": content = ConversationExportService.html(name: conversation.name, messages: loadedMessages) filename = baseName + ".html" default: content = ConversationExportService.markdown(messages: loadedMessages) filename = baseName + ".md" } _ = ConversationExportService.writeToDownloads(content, filename: filename) } } struct ConversationRow: View { let conversation: Conversation private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "dd.MM.yyyy HH:mm" return formatter.string(from: conversation.updatedAt) } /// Strips the provider prefix from OpenRouter-style IDs (e.g. "anthropic/claude-3" → "claude-3") private var modelDisplayName: String? { guard let model = conversation.primaryModel, !model.isEmpty else { return nil } if let slash = model.lastIndex(of: "/") { return String(model[model.index(after: slash)...]) } return model } var body: some View { VStack(alignment: .leading, spacing: 4) { Text(conversation.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) HStack(spacing: 6) { Label("\(conversation.messageCount)", systemImage: "message") .font(.system(size: 12)) Text("•") .font(.system(size: 12)) Text(formattedDate) .font(.system(size: 12)) if let model = modelDisplayName { Text("•") .font(.system(size: 12)) Text(model) .font(.system(size: 12)) .lineLimit(1) .truncationMode(.middle) } } .foregroundColor(.secondary) } .padding(.vertical, 5) } } #Preview { ConversationListView() }