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.
This commit is contained in:
@@ -32,6 +32,8 @@ struct SidebarView: View {
|
|||||||
@State private var folders: [Folder] = []
|
@State private var folders: [Folder] = []
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var collapsedFolders: Set<UUID> = []
|
@State private var collapsedFolders: Set<UUID> = []
|
||||||
|
@State private var selectedConversations: Set<UUID> = []
|
||||||
|
@State private var lastClickedId: UUID? = nil
|
||||||
|
|
||||||
private var filteredConversations: [Conversation] {
|
private var filteredConversations: [Conversation] {
|
||||||
guard !searchText.isEmpty else { return conversations }
|
guard !searchText.isEmpty else { return conversations }
|
||||||
@@ -42,10 +44,61 @@ struct SidebarView: View {
|
|||||||
Dictionary(grouping: filteredConversations, by: { $0.folderId })
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// New Chat / New Folder buttons
|
// New Chat / New Folder buttons — swaps to a selection toolbar while selecting
|
||||||
HStack(spacing: 4) {
|
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() }) {
|
Button(action: { chatViewModel.newConversation() }) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "square.and.pencil")
|
||||||
@@ -69,6 +122,7 @@ struct SidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("New Folder")
|
.help("New Folder")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
@@ -217,8 +271,11 @@ struct SidebarView: View {
|
|||||||
|
|
||||||
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool {
|
||||||
var moved = false
|
var moved = false
|
||||||
for idString in items {
|
// Each dropped item may itself be a newline-joined bundle of IDs (a multi-selection
|
||||||
guard let id = UUID(uuidString: idString),
|
// 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 })
|
let conversation = conversations.first(where: { $0.id == id })
|
||||||
else { continue }
|
else { continue }
|
||||||
moveConversation(conversation, toFolder: folderId)
|
moveConversation(conversation, toFolder: folderId)
|
||||||
@@ -231,15 +288,29 @@ struct SidebarView: View {
|
|||||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
private func conversationRow(_ conversation: Conversation) -> some View {
|
||||||
SidebarConversationRow(conversation: conversation)
|
SidebarConversationRow(conversation: conversation)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture(count: 2) {
|
||||||
chatViewModel.loadConversation(conversation)
|
chatViewModel.loadConversation(conversation)
|
||||||
|
selectedConversations.removeAll()
|
||||||
|
}
|
||||||
|
.onTapGesture(count: 1) {
|
||||||
|
handleRowTap(conversation)
|
||||||
}
|
}
|
||||||
.listRowBackground(
|
.listRowBackground(
|
||||||
chatViewModel.currentConversationName == conversation.name
|
chatViewModel.currentConversationName == conversation.name
|
||||||
? Color.confabAccent.opacity(0.15)
|
? Color.confabAccent.opacity(0.15)
|
||||||
|
: selectedConversations.contains(conversation.id)
|
||||||
|
? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35)
|
||||||
: Color.clear
|
: Color.clear
|
||||||
)
|
)
|
||||||
.draggable(conversation.id.uuidString) {
|
.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)
|
Text(conversation.name)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -247,6 +318,7 @@ struct SidebarView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
|
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
deleteConversation(conversation)
|
deleteConversation(conversation)
|
||||||
@@ -264,7 +336,7 @@ struct SidebarView: View {
|
|||||||
Menu {
|
Menu {
|
||||||
if conversation.folderId != nil {
|
if conversation.folderId != nil {
|
||||||
Button {
|
Button {
|
||||||
moveConversation(conversation, toFolder: nil)
|
moveConversationOrSelection(conversation, toFolder: nil)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
||||||
}
|
}
|
||||||
@@ -273,7 +345,7 @@ struct SidebarView: View {
|
|||||||
ForEach(folders) { folder in
|
ForEach(folders) { folder in
|
||||||
if folder.id != conversation.folderId {
|
if folder.id != conversation.folderId {
|
||||||
Button {
|
Button {
|
||||||
moveConversation(conversation, toFolder: folder.id)
|
moveConversationOrSelection(conversation, toFolder: folder.id)
|
||||||
} label: {
|
} label: {
|
||||||
Text(folder.name)
|
Text(folder.name)
|
||||||
}
|
}
|
||||||
@@ -286,7 +358,8 @@ struct SidebarView: View {
|
|||||||
Label("New Folder…", systemImage: "folder.badge.plus")
|
Label("New Folder…", systemImage: "folder.badge.plus")
|
||||||
}
|
}
|
||||||
} label: {
|
} 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 {
|
Button {
|
||||||
renameConversation(conversation)
|
renameConversation(conversation)
|
||||||
@@ -311,6 +384,7 @@ struct SidebarView: View {
|
|||||||
withAnimation {
|
withAnimation {
|
||||||
conversations.removeAll { $0.id == conversation.id }
|
conversations.removeAll { $0.id == conversation.id }
|
||||||
}
|
}
|
||||||
|
selectedConversations.remove(conversation.id)
|
||||||
GitSyncService.shared.syncAfterDeletion()
|
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) {
|
private func createFolderPrompt(andMove conversation: Conversation? = nil) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ struct ConversationListView: View {
|
|||||||
@State private var collapsedFolders: Set<UUID> = []
|
@State private var collapsedFolders: Set<UUID> = []
|
||||||
@State private var selectedConversations: Set<UUID> = []
|
@State private var selectedConversations: Set<UUID> = []
|
||||||
@State private var isSelecting = false
|
@State private var isSelecting = false
|
||||||
|
@State private var lastClickedId: UUID? = nil
|
||||||
@State private var useSemanticSearch = false
|
@State private var useSemanticSearch = false
|
||||||
@State private var semanticResults: [Conversation] = []
|
@State private var semanticResults: [Conversation] = []
|
||||||
@State private var isSearching = false
|
@State private var isSearching = false
|
||||||
@@ -60,6 +61,19 @@ struct ConversationListView: View {
|
|||||||
Dictionary(grouping: filteredConversations, by: { $0.folderId })
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
@@ -72,6 +86,7 @@ struct ConversationListView: View {
|
|||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
isSelecting = false
|
isSelecting = false
|
||||||
selectedConversations.removeAll()
|
selectedConversations.removeAll()
|
||||||
|
lastClickedId = nil
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
@@ -87,6 +102,28 @@ struct ConversationListView: View {
|
|||||||
.buttonStyle(.plain)
|
.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 {
|
if !selectedConversations.isEmpty {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
deleteSelected()
|
deleteSelected()
|
||||||
@@ -287,6 +324,7 @@ struct ConversationListView: View {
|
|||||||
loadConversations()
|
loadConversations()
|
||||||
selectedConversations.removeAll()
|
selectedConversations.removeAll()
|
||||||
isSelecting = false
|
isSelecting = false
|
||||||
|
lastClickedId = nil
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -299,6 +337,7 @@ struct ConversationListView: View {
|
|||||||
if isSelecting {
|
if isSelecting {
|
||||||
Button {
|
Button {
|
||||||
toggleSelection(conversation.id)
|
toggleSelection(conversation.id)
|
||||||
|
lastClickedId = conversation.id
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
|
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
|
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
|
||||||
@@ -310,13 +349,7 @@ struct ConversationListView: View {
|
|||||||
ConversationRow(conversation: conversation)
|
ConversationRow(conversation: conversation)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if isSelecting {
|
handleRowTap(conversation, index: index)
|
||||||
toggleSelection(conversation.id)
|
|
||||||
} else {
|
|
||||||
selectedIndex = index
|
|
||||||
onLoad?(conversation)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -382,7 +415,7 @@ struct ConversationListView: View {
|
|||||||
Menu {
|
Menu {
|
||||||
if conversation.folderId != nil {
|
if conversation.folderId != nil {
|
||||||
Button {
|
Button {
|
||||||
moveConversation(conversation, toFolder: nil)
|
moveConversationOrSelection(conversation, toFolder: nil)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
Label("Remove from Folder", systemImage: "folder.badge.minus")
|
||||||
}
|
}
|
||||||
@@ -391,14 +424,15 @@ struct ConversationListView: View {
|
|||||||
ForEach(folders) { folder in
|
ForEach(folders) { folder in
|
||||||
if folder.id != conversation.folderId {
|
if folder.id != conversation.folderId {
|
||||||
Button {
|
Button {
|
||||||
moveConversation(conversation, toFolder: folder.id)
|
moveConversationOrSelection(conversation, toFolder: folder.id)
|
||||||
} label: {
|
} label: {
|
||||||
Text(folder.name)
|
Text(folder.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} 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 {
|
Menu {
|
||||||
Button {
|
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) {
|
private func renameFolderPrompt(_ folder: Folder) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let alert = NSAlert()
|
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<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() {
|
private func deleteSelected() {
|
||||||
for id in selectedConversations {
|
for id in selectedConversations {
|
||||||
do {
|
do {
|
||||||
@@ -591,6 +694,7 @@ struct ConversationListView: View {
|
|||||||
selectedConversations.removeAll()
|
selectedConversations.removeAll()
|
||||||
isSelecting = false
|
isSelecting = false
|
||||||
}
|
}
|
||||||
|
lastClickedId = nil
|
||||||
selectedIndex = 0
|
selectedIndex = 0
|
||||||
GitSyncService.shared.syncAfterDeletion()
|
GitSyncService.shared.syncAfterDeletion()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user