From ecd01dfb37a66c5ece604d6ddd64ea28ae291379 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 00:58:11 -0700 Subject: [PATCH] add sidebar multi-selection with shift-click, cmd-click, and batch delete --- src/SidebarView.swift | 98 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/src/SidebarView.swift b/src/SidebarView.swift index 7674bc1..2d3696e 100644 --- a/src/SidebarView.swift +++ b/src/SidebarView.swift @@ -2,6 +2,8 @@ import SwiftUI struct SidebarView: View { @ObservedObject var state: AppState + @State private var selection: Set = [] + @State private var lastClickedID: UUID? private let dateFormatter: DateFormatter = { let f = DateFormatter() @@ -38,46 +40,106 @@ struct SidebarView: View { Spacer() } } else { - List(state.noteList) { note in - NoteRow( - note: note, - isSelected: note.id == state.currentNoteID, - dateFormatter: dateFormatter - ) - .contentShape(Rectangle()) - .onTapGesture { state.loadNote(note.id) } - .contextMenu { - Button("Delete") { state.deleteNote(note.id) } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(state.noteList.enumerated()), id: \.element.id) { index, note in + NoteRow( + note: note, + isActive: note.id == state.currentNoteID, + isSelected: selection.contains(note.id), + dateFormatter: dateFormatter + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + selection = [note.id] + lastClickedID = note.id + state.loadNote(note.id) + } + .onTapGesture(count: 1) { + handleClick(note: note, index: index, modifiers: currentModifiers()) + } + .contextMenu { + if selection.count > 1 && selection.contains(note.id) { + Button("Delete \(selection.count) Notes") { + state.deleteNotes(selection) + selection.removeAll() + lastClickedID = nil + } + } else { + Button("Delete") { state.deleteNote(note.id) } + } + } + } } - .listRowBackground( - note.id == state.currentNoteID - ? Color(ns: Theme.current.surface1) - : Color.clear - ) } - .listStyle(.plain) + .onDeleteCommand { + guard !selection.isEmpty else { return } + state.deleteNotes(selection) + selection.removeAll() + lastClickedID = nil + } } } .background(Color(ns: Theme.current.mantle)) .onAppear { state.refreshNoteList() } } + + private func handleClick(note: NoteInfo, index: Int, modifiers: EventModifiers) { + if modifiers.contains(.command) { + if selection.contains(note.id) { + selection.remove(note.id) + } else { + selection.insert(note.id) + } + lastClickedID = note.id + } else if modifiers.contains(.shift), let lastID = lastClickedID { + if let lastIndex = state.noteList.firstIndex(where: { $0.id == lastID }) { + let range = min(lastIndex, index)...max(lastIndex, index) + for i in range { + selection.insert(state.noteList[i].id) + } + } + } else { + selection = [note.id] + lastClickedID = note.id + state.loadNote(note.id) + } + } + + private func currentModifiers() -> EventModifiers { + let flags = NSEvent.modifierFlags + var mods: EventModifiers = [] + if flags.contains(.command) { mods.insert(.command) } + if flags.contains(.shift) { mods.insert(.shift) } + return mods + } } struct NoteRow: View { let note: NoteInfo + let isActive: Bool let isSelected: Bool let dateFormatter: DateFormatter var body: some View { VStack(alignment: .leading, spacing: 2) { Text(note.title) - .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .font(.system(size: 13, weight: isActive ? .semibold : .regular)) .foregroundColor(Color(ns: Theme.current.text)) .lineLimit(1) Text(dateFormatter.string(from: note.lastModified)) .font(.system(size: 11)) .foregroundColor(Color(ns: Theme.current.subtext0)) } - .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background( + isActive + ? Color(ns: Theme.current.surface1) + : isSelected + ? Color(ns: Theme.current.surface0) + : Color.clear + ) } }