From b6653c9ffaaa5ed56b32a2f9786be1a6937cfe9c Mon Sep 17 00:00:00 2001 From: momenbasel Date: Wed, 12 Aug 2026 09:58:18 +0300 Subject: [PATCH] Add multi-select bulk deletion Command-click toggles a card in and out of the selection and Shift-click extends a contiguous range from the anchor, using modifier-scoped tap gestures that read the modifiers of the actual click event. Deleting more than one clip always shows a confirmation naming the exact count, whether triggered from Cmd-Backspace, Forward Delete, the context menu, or the Delete chip in the top bar. The confirmation counts exactly the visible selected cards and deletes that same set, scoped to the current history or pinboard strip. The selection set is reconciled after every structural change - capture, retention trims, remote CloudKit applies and deletes, clears, and pinboard removal - so highlights, counts, and bulk delete can never refer to cards that are no longer on screen. New captures no longer steal the selection while a multi-select is active, and Escape clears the selection before it clears search or hides the bar. Supersedes #40. Co-authored-by: Alvie Stoddard Co-authored-by: Cursor --- Sources/Pesty/AppController.swift | 45 +++++++-- Sources/Pesty/Store/ClipboardStore.swift | 123 ++++++++++++++++++++--- Sources/Pesty/UI/BarView.swift | 20 +++- Sources/Pesty/UI/ClipCardView.swift | 13 ++- 4 files changed, 178 insertions(+), 23 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index f01605d..072c0e4 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -277,6 +277,38 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func deleteEffectiveSelection() { + let selection = store.effectiveSelectionIDs + let targets = store.visibleItems.filter { selection.contains($0.id) } + guard !targets.isEmpty else { return } + if targets.count == 1 { + store.delete(targets[0]) + return + } + suppressAutoHide = true + defer { suppressAutoHide = false } + let alert = NSAlert() + alert.messageText = "Delete \(targets.count) Clips?" + #if MAS + alert.informativeText = "There is no undo. When iCloud sync is on, these clips are also removed from your other devices." + #else + alert.informativeText = "There is no undo." + #endif + let confirm = alert.addButton(withTitle: "Delete \(targets.count) Clips") + confirm.hasDestructiveAction = true + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + store.delete(items: targets) + } + + func deleteSelection(containing item: ClipItem) { + if store.multiSelectedIDs.contains(item.id) { + deleteEffectiveSelection() + } else { + store.delete(item) + } + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { @@ -355,7 +387,9 @@ final class AppController: NSObject, NSApplicationDelegate { switch code { case kVK_Escape: - if !store.searchText.isEmpty { + if !store.multiSelectedIDs.isEmpty { + store.clearMultiSelection() + } else if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() searchClearedAt = Date() } else { hideBar() } @@ -367,7 +401,7 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_RightArrow, kVK_DownArrow: store.moveSelection(by: 1); return nil case kVK_Delete: - if cmd, let sel = store.selectedItem { store.delete(sel); return nil } + if cmd { deleteEffectiveSelection(); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst() if store.searchText.isEmpty { searchClearedAt = Date() } @@ -381,13 +415,12 @@ final class AppController: NSObject, NSApplicationDelegate { // "delete a clip". There is no undo, and deletions replicate to // other devices when sync is on. if !event.isARepeat, - Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown, - let sel = store.selectedItem { - store.delete(sel) + Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown { + deleteEffectiveSelection() } return nil case kVK_ForwardDelete: - if let sel = store.selectedItem { store.delete(sel) } + deleteEffectiveSelection() return nil default: break diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index b999741..264becb 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -18,9 +18,15 @@ final class ClipboardStore { private(set) var history: [ClipItem] = [] private(set) var pinboards: [Pinboard] = [] - var source: BarSource = .history - var searchText: String = "" + var source: BarSource = .history { + didSet { if source != oldValue { clearMultiSelection() } } + } + var searchText: String = "" { + didSet { if searchText != oldValue { clearMultiSelection() } } + } var selectedID: UUID? + private(set) var multiSelectedIDs: Set = [] + private var selectionAnchorID: UUID? /// Bumped every time the bar is about to present. The hosting view is /// built once and cached, so the strip keeps its scroll offset across @@ -101,14 +107,18 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) - if source == .history && searchText.isEmpty { selectedID = existing.id } + if source == .history && searchText.isEmpty && multiSelectedIDs.isEmpty { + selectedID = existing.id + selectionAnchorID = existing.id + } scheduleSave() return } history.insert(item, at: 0) trimHistory() - if source == .history && searchText.isEmpty { + if source == .history && searchText.isEmpty && multiSelectedIDs.isEmpty { selectedID = item.id + selectionAnchorID = item.id } scheduleSave() } @@ -162,6 +172,7 @@ final class ClipboardStore { for item in removed { deleteImageFile(item) } markRetentionPruned(removed) if let sel = selectedID, removed.contains(where: { $0.id == sel }) { selectFirst() } + reconcileMultiSelection() } private func markRetentionPruned(_ items: [ClipItem]) { @@ -179,29 +190,38 @@ final class ClipboardStore { /// Deleting exactly what was removed also closes an image-file leak: the /// old cross-container removal deleted entries under two file names but /// cleaned up only one of them. - func delete(_ item: ClipItem) { + func delete(_ item: ClipItem) { delete(items: [item]) } + + func delete(items: [ClipItem]) { + let ids = Set(items.map(\.id)) + guard !ids.isEmpty else { return } // Captured before removal so repeated deletes walk down the list // instead of snapping back to the newest clip every time. - let deletedIndex = visibleItems.firstIndex(where: { $0.id == item.id }) + let deletedIndex = visibleItems.firstIndex(where: { ids.contains($0.id) }) + let selectionDeleted = selectedID.map { ids.contains($0) } ?? false let removed: [ClipItem] switch source { case .history: - removed = history.filter { $0.id == item.id } - history.removeAll { $0.id == item.id } + removed = history.filter { ids.contains($0.id) } + history.removeAll { ids.contains($0.id) } case .pinboard(let boardID): guard let boardIndex = pinboards.firstIndex(where: { $0.id == boardID }) else { return } - removed = pinboards[boardIndex].items.filter { $0.id == item.id } - pinboards[boardIndex].items.removeAll { $0.id == item.id } + removed = pinboards[boardIndex].items.filter { ids.contains($0.id) } + pinboards[boardIndex].items.removeAll { ids.contains($0.id) } } for entry in removed { deleteImageFile(entry) } - if selectedID == item.id { - let items = visibleItems - if let index = deletedIndex, !items.isEmpty { - selectedID = items[min(index, items.count - 1)].id + multiSelectedIDs.subtract(ids) + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + if selectionDeleted { + let remaining = visibleItems + if let index = deletedIndex, !remaining.isEmpty { + selectedID = remaining[min(index, remaining.count - 1)].id } else { selectFirst() } + selectionAnchorID = selectedID } + reconcileMultiSelection() scheduleSave() } @@ -210,6 +230,7 @@ final class ClipboardStore { history.removeAll() selectedID = nil for item in old { deleteImageFile(item) } + reconcileMultiSelection() scheduleSave() } @@ -233,6 +254,7 @@ final class ClipboardStore { let removedItems = pinboards[i].items pinboards.remove(at: i) for item in removedItems { deleteImageFile(item) } + reconcileMultiSelection() scheduleSave() } @@ -269,13 +291,83 @@ final class ClipboardStore { func selectFirst() { selectedID = visibleItems.first?.id } + var effectiveSelectionIDs: Set { + if !multiSelectedIDs.isEmpty { return multiSelectedIDs } + return selectedID.map { [$0] } ?? [] + } + + func isSelected(_ id: UUID) -> Bool { + if multiSelectedIDs.isEmpty { return selectedID == id } + return multiSelectedIDs.contains(id) + } + + func select(_ id: UUID) { + selectedID = id + selectionAnchorID = id + multiSelectedIDs = [] + } + + func toggleSelection(_ id: UUID) { + guard visibleItems.contains(where: { $0.id == id }) else { return } + if multiSelectedIDs.isEmpty, let sel = selectedID, sel != id, + visibleItems.contains(where: { $0.id == sel }) { + multiSelectedIDs = [sel] + } + if multiSelectedIDs.contains(id) { + multiSelectedIDs.remove(id) + if selectedID == id { selectedID = multiSelectedIDs.first ?? visibleItems.first?.id } + if selectionAnchorID == id { selectionAnchorID = selectedID } + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + } else { + multiSelectedIDs.insert(id) + selectedID = id + if selectionAnchorID == nil { selectionAnchorID = id } + if multiSelectedIDs.count == 1 { multiSelectedIDs = [] } + } + } + + func extendSelection(to id: UUID) { + let items = visibleItems + guard let targetIndex = items.firstIndex(where: { $0.id == id }) else { return } + guard let anchor = selectionAnchorID ?? selectedID, + let anchorIndex = items.firstIndex(where: { $0.id == anchor }) else { + select(id) + return + } + let range = items[min(anchorIndex, targetIndex)...max(anchorIndex, targetIndex)] + multiSelectedIDs = Set(range.map(\.id)) + selectedID = id + selectionAnchorID = anchor + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + } + + func clearMultiSelection() { + multiSelectedIDs = [] + selectionAnchorID = selectedID + } + + private func reconcileMultiSelection() { + guard !multiSelectedIDs.isEmpty else { return } + let visible = Set(visibleItems.map(\.id)) + multiSelectedIDs.formIntersection(visible) + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + if let anchor = selectionAnchorID, !visible.contains(anchor) { + selectionAnchorID = selectedID + } + if let sel = selectedID, !visible.contains(sel) { + selectedID = multiSelectedIDs.first ?? visibleItems.first?.id + } + } + func prepareForBarPresentation() { applyRetentionPolicy() + clearMultiSelection() barPresentationToken &+= 1 selectFirst() } func moveSelection(by delta: Int) { + clearMultiSelection() let items = visibleItems guard !items.isEmpty else { return } guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { @@ -283,6 +375,7 @@ final class ClipboardStore { } let next = max(0, min(items.count - 1, idx + delta)) selectedID = items[next].id + selectionAnchorID = selectedID } func imageURL(for item: ClipItem) -> URL? { @@ -382,6 +475,7 @@ final class ClipboardStore { } trimHistory() if selectedID == nil { selectFirst() } + reconcileMultiSelection() scheduleSave() } @@ -402,6 +496,7 @@ final class ClipboardStore { deleteImageFile(item) } if let sel = selectedID, set.contains(sel) { selectFirst() } + reconcileMultiSelection() scheduleSave() } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 54bf40d..87dcd77 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -42,6 +42,9 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if store.multiSelectedIDs.count > 1 { + bulkDeleteButton + } moreMenu } .padding(.horizontal, 18) @@ -86,6 +89,21 @@ struct BarView: View { .animation(.easeOut(duration: 0.15), value: store.searchText.isEmpty) } + private var bulkDeleteButton: some View { + let count = store.multiSelectedIDs.count + return Button(role: .destructive) { + AppController.shared.deleteEffectiveSelection() + } label: { + Label("Delete \(count)", systemImage: "trash") + .font(.system(size: 12.5, weight: .medium)) + .lineLimit(1) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Delete \(count) selected clips (⌘⌫)") + .accessibilityLabel("Delete \(count) selected clips") + } + private var moreMenu: some View { Menu { Button("Settings…") { AppController.shared.showSettings() } @@ -117,7 +135,7 @@ struct BarView: View { ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in ClipCardView(item: item, index: index, - selected: item.id == store.selectedID) + selected: store.isSelected(item.id)) .id(item.id) .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 3bf9b05..7e40e5a 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -40,7 +40,9 @@ struct ClipCardView: View { .contentShape(Rectangle()) .onHover { hovering = $0 } .onTapGesture(count: 2) { AppController.shared.pasteItem(item) } - .onTapGesture { store.selectedID = item.id } + .onTapGesture { store.select(item.id) } + .highPriorityGesture(TapGesture().modifiers(.shift).onEnded { store.extendSelection(to: item.id) }) + .highPriorityGesture(TapGesture().modifiers(.command).onEnded { store.toggleSelection(item.id) }) .onDrag { ClipDragProvider.make(for: item) } .contextMenu { menu } } @@ -213,6 +215,13 @@ struct ClipCardView: View { } } Divider() - Button("Delete", role: .destructive) { store.delete(item) } + Button(deleteMenuTitle, role: .destructive) { + AppController.shared.deleteSelection(containing: item) + } + } + + private var deleteMenuTitle: String { + let count = store.multiSelectedIDs.contains(item.id) ? store.multiSelectedIDs.count : 1 + return count > 1 ? "Delete \(count) Clips" : "Delete" } }