2.5.0 #10

Merged
rune merged 41 commits from 2.5.0 into main 2026-08-03 08:33:23 +02:00
3 changed files with 330 additions and 41 deletions
Showing only changes of commit 7f5d858b2a - Show all commits
+162 -31
View File
@@ -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,32 +44,84 @@ 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) {
Button(action: { chatViewModel.newConversation() }) { if !selectedConversations.isEmpty {
HStack(spacing: 8) { Text("\(selectedConversations.count) selected")
Image(systemName: "square.and.pencil") .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)) .font(.system(size: 14))
Text("New Chat") .foregroundColor(.confabPrimary)
.font(.system(size: 14, weight: .medium))
} }
.foregroundColor(.confabPrimary) .menuStyle(.borderlessButton)
.contentShape(Rectangle()) .fixedSize()
} .help("Move to Folder")
.buttonStyle(.plain)
Spacer() Button { selectedConversations.removeAll() } label: {
Image(systemName: "xmark.circle.fill")
Button(action: { createFolderPrompt() }) { .font(.system(size: 14))
Image(systemName: "folder.badge.plus") .foregroundStyle(.secondary)
.font(.system(size: 14)) }
.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) .foregroundColor(.confabPrimary)
.contentShape(Rectangle()) .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(.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,21 +288,36 @@ 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)
: Color.clear : selectedConversations.contains(conversation.id)
? Color(nsColor: .selectedContentBackgroundColor).opacity(0.35)
: Color.clear
) )
.draggable(conversation.id.uuidString) { .draggable(dragPayload(for: conversation)) {
Text(conversation.name) if selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
.font(.system(size: 13, weight: .medium)) Text("\(selectedConversations.count) conversations")
.foregroundStyle(.white) .font(.system(size: 13, weight: .medium))
.padding(.horizontal, 10) .foregroundStyle(.white)
.padding(.vertical, 6) .padding(.horizontal, 10)
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) .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) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) { Button(role: .destructive) {
@@ -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()
+114 -10
View File
@@ -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]])
}
}