From 7f5d858b2a7a0eb86ad6a8720233933a5063371c Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Sun, 2 Aug 2026 16:54:05 +0200 Subject: [PATCH] Add multi-select + bulk move-to-folder to both conversation lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- oAI/Views/Main/SidebarView.swift | 193 +++++++++++++++--- oAI/Views/Screens/ConversationListView.swift | 124 ++++++++++- .../ConversationListViewPureLogicTests.swift | 54 +++++ 3 files changed, 330 insertions(+), 41 deletions(-) create mode 100644 oAITests/ConversationListViewPureLogicTests.swift diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index 1d83e21..4bebeb9 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -32,6 +32,8 @@ struct SidebarView: View { @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 } @@ -42,32 +44,84 @@ struct SidebarView: View { 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 + // New Chat / New Folder buttons — swaps to a selection toolbar while selecting HStack(spacing: 4) { - Button(action: { chatViewModel.newConversation() }) { - HStack(spacing: 8) { - Image(systemName: "square.and.pencil") + 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)) - Text("New Chat") - .font(.system(size: 14, weight: .medium)) + .foregroundColor(.confabPrimary) } - .foregroundColor(.confabPrimary) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .fixedSize() + .help("Move to Folder") - Spacer() - - Button(action: { createFolderPrompt() }) { - Image(systemName: "folder.badge.plus") - .font(.system(size: 14)) + 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") } - .buttonStyle(.plain) - .help("New Folder") } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -217,8 +271,11 @@ struct SidebarView: View { private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { var moved = false - for idString in items { - guard let id = UUID(uuidString: idString), + // 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) @@ -231,21 +288,36 @@ struct SidebarView: View { private func conversationRow(_ conversation: Conversation) -> some View { SidebarConversationRow(conversation: conversation) .contentShape(Rectangle()) - .onTapGesture { + .onTapGesture(count: 2) { chatViewModel.loadConversation(conversation) + selectedConversations.removeAll() + } + .onTapGesture(count: 1) { + handleRowTap(conversation) } .listRowBackground( chatViewModel.currentConversationName == conversation.name ? Color.confabAccent.opacity(0.15) - : Color.clear + : selectedConversations.contains(conversation.id) + ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35) + : Color.clear ) - .draggable(conversation.id.uuidString) { - Text(conversation.name) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) + .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) { @@ -264,7 +336,7 @@ struct SidebarView: View { Menu { if conversation.folderId != nil { Button { - moveConversation(conversation, toFolder: nil) + moveConversationOrSelection(conversation, toFolder: nil) } label: { Label("Remove from Folder", systemImage: "folder.badge.minus") } @@ -273,7 +345,7 @@ struct SidebarView: View { ForEach(folders) { folder in if folder.id != conversation.folderId { Button { - moveConversation(conversation, toFolder: folder.id) + moveConversationOrSelection(conversation, toFolder: folder.id) } label: { Text(folder.name) } @@ -286,7 +358,8 @@ struct SidebarView: View { Label("New Folder…", systemImage: "folder.badge.plus") } } label: { - Label("Move to Folder", systemImage: "folder") + Label(selectedConversations.contains(conversation.id) && selectedConversations.count > 1 + ? "Move \(selectedConversations.count) to Folder" : "Move to Folder", systemImage: "folder") } Button { renameConversation(conversation) @@ -311,6 +384,7 @@ struct SidebarView: View { withAnimation { conversations.removeAll { $0.id == conversation.id } } + selectedConversations.remove(conversation.id) GitSyncService.shared.syncAfterDeletion() } @@ -352,6 +426,63 @@ struct SidebarView: View { } } + /// 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() diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 708426d..4df7e6f 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -32,6 +32,7 @@ struct ConversationListView: View { @State private var collapsedFolders: Set = [] @State private var selectedConversations: Set = [] @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 @@ -60,6 +61,19 @@ struct ConversationListView: View { 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. + 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) { // Header @@ -72,6 +86,7 @@ struct ConversationListView: View { Button("Cancel") { isSelecting = false selectedConversations.removeAll() + lastClickedId = nil } .buttonStyle(.plain) @@ -87,6 +102,28 @@ struct ConversationListView: View { .buttonStyle(.plain) } + if !selectedConversations.isEmpty { + Menu { + if !folders.isEmpty { + ForEach(folders) { folder in + Button(folder.name) { + moveSelectedToFolder(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() @@ -287,6 +324,7 @@ struct ConversationListView: View { loadConversations() selectedConversations.removeAll() isSelecting = false + lastClickedId = nil } ) } @@ -299,6 +337,7 @@ struct ConversationListView: View { 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) @@ -310,13 +349,7 @@ struct ConversationListView: View { ConversationRow(conversation: conversation) .contentShape(Rectangle()) .onTapGesture { - if isSelecting { - toggleSelection(conversation.id) - } else { - selectedIndex = index - onLoad?(conversation) - dismiss() - } + handleRowTap(conversation, index: index) } Spacer() @@ -382,7 +415,7 @@ struct ConversationListView: View { Menu { if conversation.folderId != nil { Button { - moveConversation(conversation, toFolder: nil) + moveConversationOrSelection(conversation, toFolder: nil) } label: { Label("Remove from Folder", systemImage: "folder.badge.minus") } @@ -391,14 +424,15 @@ struct ConversationListView: View { ForEach(folders) { folder in if folder.id != conversation.folderId { Button { - moveConversation(conversation, toFolder: folder.id) + moveConversationOrSelection(conversation, toFolder: folder.id) } label: { Text(folder.name) } } } } label: { - Label("Move to Folder", systemImage: "folder") + Label(isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 + ? "Move \(selectedConversations.count) to Folder" : "Move to Folder", systemImage: "folder") } Menu { Button { @@ -532,6 +566,25 @@ struct ConversationListView: View { } } + /// 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() @@ -578,6 +631,56 @@ struct ConversationListView: View { } } + /// 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 { + 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 { @@ -591,6 +694,7 @@ struct ConversationListView: View { selectedConversations.removeAll() isSelecting = false } + lastClickedId = nil selectedIndex = 0 GitSyncService.shared.syncAfterDeletion() } diff --git a/oAITests/ConversationListViewPureLogicTests.swift b/oAITests/ConversationListViewPureLogicTests.swift new file mode 100644 index 0000000..d73cb73 --- /dev/null +++ b/oAITests/ConversationListViewPureLogicTests.swift @@ -0,0 +1,54 @@ +// +// ConversationListViewPureLogicTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import Confab + +@Suite("ConversationListView.idsInRange") +struct ConversationListViewPureLogicTests { + + private let ids = (0..<6).map { _ in UUID() } + + @Test("Forward range includes anchor and target inclusive") + func forwardRange() { + let result = ConversationListView.idsInRange(orderedIds: ids, anchorId: ids[1], targetId: ids[4]) + #expect(result == Set(ids[1...4])) + } + + @Test("Backward range (target above anchor) still selects the span between them") + func backwardRange() { + let result = ConversationListView.idsInRange(orderedIds: ids, anchorId: ids[4], targetId: ids[1]) + #expect(result == Set(ids[1...4])) + } + + @Test("Anchor equal to target selects just that one id") + func sameAnchorAndTarget() { + let result = ConversationListView.idsInRange(orderedIds: ids, anchorId: ids[2], targetId: ids[2]) + #expect(result == [ids[2]]) + } + + @Test("Nil anchor (first Shift-click) falls back to just the target") + func nilAnchorFallsBackToTarget() { + let result = ConversationListView.idsInRange(orderedIds: ids, anchorId: nil, targetId: ids[3]) + #expect(result == [ids[3]]) + } + + @Test("Anchor no longer present in the ordered list falls back to just the target") + func missingAnchorFallsBackToTarget() { + let staleAnchor = UUID() + let result = ConversationListView.idsInRange(orderedIds: ids, anchorId: staleAnchor, targetId: ids[3]) + #expect(result == [ids[3]]) + } + + @Test("Single-element list selects that element") + func singleElementList() { + let solo = [ids[0]] + let result = ConversationListView.idsInRange(orderedIds: solo, anchorId: ids[0], targetId: ids[0]) + #expect(result == [ids[0]]) + } +}