Files
oai-swift/oAI/Views/Main/SidebarView.swift
T
rune 7f5d858b2a Add multi-select + bulk move-to-folder to both conversation lists
ConversationListView (advanced list, ⌘L): ⌘-click toggles a row,
Shift-click selects a contiguous range, and a "Move to Folder"
toolbar button/context-menu entry moves every selected conversation
at once. Confirmed working live.

SidebarView: same capability, adapted to the sidebar's own click
model since opening a chat there previously required only a single
click. Single-click now selects only (replacing the prior selection),
⌘/Shift-click work the same as the advanced list, and double-click
opens a chat (clearing the selection). Selected rows get a distinct
neutral tint from the existing accent highlight used for the
currently-open conversation. Dragging a row that's part of a
multi-selection now bundles every selected conversation's ID into the
drag payload, so dropping on a folder moves the whole selection
instead of just the dragged row.

Range-selection math (idsInRange) is defined once on
ConversationListView and reused directly by SidebarView rather than
duplicated — it's `internal`, not `private`, specifically so both
views can share it.
2026-08-02 16:54:05 +02:00

590 lines
24 KiB
Swift

//
// 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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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<UUID> = []
@State private var selectedConversations: Set<UUID> = []
@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 })
}
/// Flattened conversation order matching what's actually rendered in the List — folders in
/// 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 folders where !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(folders) { folder in
Button(folder.name) {
moveSelectedToFolder(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(folders) { folder in
let folderConversations = conversationsByFolder[folder.id] ?? []
if !folderConversations.isEmpty || searchText.isEmpty {
Section {
if !collapsedFolders.contains(folder.id) {
ForEach(folderConversations) { conversation in
conversationRow(conversation)
}
}
} header: {
folderHeader(folder)
}
}
}
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() }
}
}
@ViewBuilder
private func folderHeader(_ folder: Folder) -> 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))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
toggleCollapsed(folder.id)
}
}
.contextMenu {
Button {
renameFolderPrompt(folder)
} label: {
Label("Rename Folder", systemImage: "pencil")
}
Button(role: .destructive) {
deleteFolder(folder)
} label: {
Label("Delete Folder", systemImage: "trash")
}
}
.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 folderId: UUID?) -> Bool {
var moved = false
// Each dropped item may itself be a newline-joined bundle of IDs (a multi-selection
// dragged together — see conversationRow's .draggable payload), so split every item
// before parsing UUIDs out of it.
for idString in items.flatMap({ $0.split(separator: "\n") }) {
guard let id = UUID(uuidString: String(idString)),
let conversation = conversations.first(where: { $0.id == id })
else { continue }
moveConversation(conversation, toFolder: folderId)
moved = true
}
return moved
}
@ViewBuilder
private func conversationRow(_ conversation: Conversation) -> some View {
SidebarConversationRow(conversation: conversation)
.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(dragPayload(for: conversation)) {
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(folders) { folder in
if folder.id != conversation.folderId {
Button {
moveConversationOrSelection(conversation, toFolder: folder.id)
} label: {
Text(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)
}
}
/// Drag payload for a row: if it's part of an active multi-selection, bundle every selected
/// conversation's ID (newline-joined — UUIDs never contain one) so dragging any one of them
/// moves the whole selection. Otherwise just this row's own ID, unchanged.
private func dragPayload(for conversation: Conversation) -> String {
if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
return selectedConversations.map { $0.uuidString }.joined(separator: "\n")
}
return conversation.id.uuidString
}
/// 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) {
#if os(macOS)
let alert = NSAlert()
alert.messageText = "New Folder"
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)
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)
folders.removeAll { $0.id == folder.id }
for i in conversations.indices where conversations[i].folderId == folder.id {
conversations[i].folderId = nil
}
} 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)
}