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 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 }
@@ -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()
+114 -10
View File
@@ -32,6 +32,7 @@ struct ConversationListView: View {
@State private var collapsedFolders: Set<UUID> = []
@State private var selectedConversations: Set<UUID> = []
@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<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() {
for id in selectedConversations {
do {
@@ -591,6 +694,7 @@ struct ConversationListView: View {
selectedConversations.removeAll()
isSelecting = false
}
lastClickedId = nil
selectedIndex = 0
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]])
}
}