// // ConversationListView.swift // oAI // // Saved conversations list // import os import SwiftUI struct ConversationListView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var conversations: [Conversation] = [] var onLoad: ((Conversation) -> Void)? private var filteredConversations: [Conversation] { if searchText.isEmpty { return conversations } return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } } var body: some View { VStack(spacing: 0) { // Header HStack { Text("Conversations") .font(.system(size: 18, weight: .bold)) Spacer() 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) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) } } .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 { List { ForEach(filteredConversations) { conversation in ConversationRow(conversation: conversation) .contentShape(Rectangle()) .onTapGesture { onLoad?(conversation) dismiss() } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { deleteConversation(conversation) } label: { Label("Delete", systemImage: "trash") } Button { exportConversation(conversation) } label: { Label("Export", systemImage: "square.and.arrow.up") } .tint(.blue) } } } .listStyle(.plain) } Divider() // Bottom bar HStack { Spacer() Button("Done") { dismiss() } .keyboardShortcut(.return, modifiers: []) .buttonStyle(.borderedProminent) .controlSize(.regular) Spacer() } .padding(.horizontal, 24) .padding(.vertical, 12) } .onAppear { loadConversations() } .frame(minWidth: 500, minHeight: 400) } private func loadConversations() { do { conversations = try DatabaseService.shared.listConversations() } catch { Log.db.error("Failed to load conversations: \(error.localizedDescription)") conversations = [] } } private func deleteConversation(_ conversation: Conversation) { do { let _ = try DatabaseService.shared.deleteConversation(id: conversation.id) withAnimation { conversations.removeAll { $0.id == conversation.id } } } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } } private func exportConversation(_ conversation: Conversation) { guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id), !loadedMessages.isEmpty else { return } let content = loadedMessages.map { msg in let header = msg.role == .user ? "**User**" : "**Assistant**" return "\(header)\n\n\(msg.content)" }.joined(separator: "\n\n---\n\n") let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md" let fileURL = downloads.appendingPathComponent(filename) try? content.write(to: fileURL, atomically: true, encoding: .utf8) } } struct ConversationRow: View { let conversation: Conversation var body: some View { VStack(alignment: .leading, spacing: 6) { Text(conversation.name) .font(.headline) HStack(spacing: 8) { Label("\(conversation.messageCount)", systemImage: "message") Text("\u{2022}") Text(conversation.updatedAt, style: .relative) } .font(.caption) .foregroundColor(.secondary) } .padding(.vertical, 4) } } #Preview { ConversationListView() }