// // SidebarView.swift // Confab // // Collapsible sidebar: new chat, conversation list, status pills // // 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 SwiftUI #if os(macOS) import AppKit #endif struct SidebarView: View { @Environment(ChatViewModel.self) private var chatViewModel @State private var conversations: [Conversation] = [] @State private var folders: [Folder] = [] @State private var searchText = "" @State private var collapsedFolders: Set = [] @State private var selectedConversations: Set = [] @State private var lastClickedId: UUID? = nil private var filteredConversations: [Conversation] { guard !searchText.isEmpty else { return conversations } 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, same pattern as ConversationListView's version. 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) { // New Chat / New Folder buttons — swaps to a selection toolbar while selecting HStack(spacing: 4) { if !selectedConversations.isEmpty { Text("\(selectedConversations.count) selected") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) Spacer() 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: { Image(systemName: "folder") .font(.system(size: 14)) .foregroundColor(.confabPrimary) } .menuStyle(.borderlessButton) .fixedSize() .help("Move to Folder") Button { selectedConversations.removeAll() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 14)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) .help("Cancel Selection") } else { Button(action: { chatViewModel.newConversation() }) { HStack(spacing: 8) { Image(systemName: "square.and.pencil") .font(.system(size: 14)) Text("New Chat") .font(.system(size: 14, weight: .medium)) } .foregroundColor(.confabPrimary) .contentShape(Rectangle()) } .buttonStyle(.plain) Spacer() Button(action: { createFolderPrompt() }) { Image(systemName: "folder.badge.plus") .font(.system(size: 14)) .foregroundColor(.confabPrimary) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("New Folder") } } .padding(.horizontal, 12) .padding(.vertical, 10) // Search field HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 12)) .foregroundStyle(.secondary) TextField("Search conversations…", text: $searchText) .textFieldStyle(.plain) .font(.system(size: 13)) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) } Divider().frame(height: 12) Button { chatViewModel.showConversations = true } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 11)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Advanced search — semantic search, bulk delete, export") } .padding(7) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) .padding(.horizontal, 8) .padding(.bottom, 6) Divider() // Conversation list if filteredConversations.isEmpty { Spacer() VStack(spacing: 8) { Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") .font(.title2) .foregroundStyle(.tertiary) Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") .font(.callout) .foregroundStyle(.secondary) } Spacer() } else if folders.isEmpty { List { ForEach(filteredConversations) { conversation in conversationRow(conversation) } } .listStyle(.sidebar) } else { List { 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(.sidebar) } } .onAppear { loadData() collapsedFolders = SettingsService.shared.collapsedFolderIds } .onChange(of: chatViewModel.currentConversationName) { loadData() } .onChange(of: chatViewModel.messages.count) { loadData() } .onChange(of: chatViewModel.showConversations) { _, isShowing in // Folders/conversations created, renamed, or deleted in the advanced // conversation list modal live in its own @State — refresh ours once it closes. if !isShowing { loadData() } } .onChange(of: chatViewModel.showSettings) { _, isShowing in // Git Sync (Settings → Sync) can import conversations/folders directly into the // database — refresh ours once the sheet closes so they show up without a relaunch. if !isShowing { loadData() } } } @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 } @ViewBuilder private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View { SidebarConversationRow(conversation: conversation) .padding(.leading, CGFloat(depth) * 14) .contentShape(Rectangle()) .onTapGesture(count: 2) { chatViewModel.loadConversation(conversation) selectedConversations.removeAll() } .onTapGesture(count: 1) { handleRowTap(conversation) } .listRowBackground( chatViewModel.currentConversationName == conversation.name ? Color.confabAccent.opacity(0.15) : selectedConversations.contains(conversation.id) ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35) : Color.clear ) .draggable(DraggedItem.conversations( selectedConversations.contains(conversation.id) && selectedConversations.count > 1 ? Array(selectedConversations) : [conversation.id] ).rawValue) { if 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) } .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) } } } Divider() Button { createFolderPrompt(andMove: conversation) } label: { Label("New Folder…", systemImage: "folder.badge.plus") } } label: { Label(selectedConversations.contains(conversation.id) && selectedConversations.count > 1 ? "Move \(selectedConversations.count) to Folder" : "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 loadData() { conversations = (try? DatabaseService.shared.listConversations()) ?? [] folders = (try? DatabaseService.shared.listFolders()) ?? [] } private func deleteConversation(_ conversation: Conversation) { _ = try? DatabaseService.shared.deleteConversation(id: conversation.id) withAnimation { conversations.removeAll { $0.id == conversation.id } } selectedConversations.remove(conversation.id) 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) if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[i].name = newName conversations[i].updatedAt = Date() } chatViewModel.didRenameConversation(id: conversation.id, newName: newName) } catch { Log.db.error("Failed to rename conversation: \(error.localizedDescription)") } #endif } 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 selectedConversations.contains(conversation.id) && selectedConversations.count > 1 { moveSelectedToFolder(folderId) } else { moveConversation(conversation, toFolder: folderId) } } /// Standard macOS row-click handling: ⌘-click toggles the individual row (additive), Shift-click /// extends/creates a contiguous range from the last-clicked row, and a plain click replaces the /// selection with just this row. Never opens the conversation — that's double-click's job. private func handleRowTap(_ conversation: Conversation) { #if os(macOS) let modifiers = NSEvent.modifierFlags if modifiers.contains(.command) { if selectedConversations.contains(conversation.id) { selectedConversations.remove(conversation.id) } else { selectedConversations.insert(conversation.id) } lastClickedId = conversation.id return } if modifiers.contains(.shift) { let orderedIds = visibleOrderedConversations.map { $0.id } selectedConversations.formUnion( ConversationListView.idsInRange(orderedIds: orderedIds, anchorId: lastClickedId, targetId: conversation.id) ) lastClickedId = conversation.id return } #endif selectedConversations = [conversation.id] lastClickedId = conversation.id } private func createFolderPrompt(andMove conversation: Conversation? = nil, 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() if let conversation = conversation { moveConversation(conversation, toFolder: folder.id) } } catch { Log.db.error("Failed to create folder: \(error.localizedDescription)") } #endif } private func sortFolders() { folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } 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)") } } } // MARK: - Sidebar conversation row struct SidebarConversationRow: View { let conversation: Conversation private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "dd.MM.yyyy" return formatter.string(from: conversation.updatedAt) } var body: some View { VStack(alignment: .leading, spacing: 2) { Text(conversation.name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.primary) .lineLimit(1) HStack(spacing: 4) { Text("^[\(conversation.messageCount) message](inflect: true)") .font(.system(size: 11)) Text("·") .font(.system(size: 11)) Text(formattedDate) .font(.system(size: 11)) } .foregroundStyle(.secondary) } .padding(.vertical, 2) } } #Preview { SidebarView() .environment(ChatViewModel()) .frame(width: 240, height: 600) }