NSWorkspace.shared.open() silently drops #fragment anchors on file:// URLs, so "Fix It Myself" always landed on the Help Book index instead of the relevant section. Replaced with GitSyncManualFixSheet, an in-app sheet showing the real conflicting filenames and sync path. Also indent conversation rows one level deeper than their containing folder in the sidebar and conversation list, so nesting is visible on the conversations themselves and not just the folder headers.
926 lines
37 KiB
Swift
926 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, 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<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()
|
|
}
|