Covers the per-file license-header comment (~80 Swift files) plus the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
925 lines
37 KiB
Swift
925 lines
37 KiB
Swift
//
|
|
// 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
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
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<UUID> = []
|
|
@State private var selectedConversations: Set<UUID> = []
|
|
@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<UUID> { 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)
|
|
}
|
|
}
|
|
} 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) -> 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")
|
|
}
|
|
}
|
|
.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<UUID> {
|
|
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()
|
|
}
|