Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() }
Expand All @@ -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() }
Expand All @@ -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
Expand Down
123 changes: 109 additions & 14 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []
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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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]) {
Expand All @@ -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()
}

Expand All @@ -210,6 +230,7 @@ final class ClipboardStore {
history.removeAll()
selectedID = nil
for item in old { deleteImageFile(item) }
reconcileMultiSelection()
scheduleSave()
}

Expand All @@ -233,6 +254,7 @@ final class ClipboardStore {
let removedItems = pinboards[i].items
pinboards.remove(at: i)
for item in removedItems { deleteImageFile(item) }
reconcileMultiSelection()
scheduleSave()
}

Expand Down Expand Up @@ -269,20 +291,91 @@ final class ClipboardStore {

func selectFirst() { selectedID = visibleItems.first?.id }

var effectiveSelectionIDs: Set<UUID> {
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
Comment on lines +344 to +346

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-anchor after changing the visible collection

When searchText or source changes, this records the old selectedID as the anchor, and the subsequent selectFirst() call changes only selectedID. If the old item is absent from the new search results or pinboard, the first Shift-click reaches the missing-anchor fallback in extendSelection(to:) and selects only the clicked card instead of the expected range. The anchor should be reset to the newly selected first item after the visible collection changes.

Useful? React with 👍 / 👎.

}

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
Comment on lines +357 to +358

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep focus within the reconciled multi-selection

When a sync deletion removes the focused selected clip while other selected clips remain, applyRemoteDeletes calls selectFirst() before this method, so selectedID can become a visible but unselected clip. This condition therefore leaves the highlighted multi-selection and keyboard focus inconsistent: Return pastes an unrelated unhighlighted clip, while bulk deletion still targets the highlighted clips. Reconciliation should also move selectedID into multiSelectedIDs whenever a multi-selection remains.

Useful? React with 👍 / 👎.

}
}

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 {
selectedID = items.first?.id; return
}
let next = max(0, min(items.count - 1, idx + delta))
selectedID = items[next].id
selectionAnchorID = selectedID
}

func imageURL(for item: ClipItem) -> URL? {
Expand Down Expand Up @@ -382,6 +475,7 @@ final class ClipboardStore {
}
trimHistory()
if selectedID == nil { selectFirst() }
reconcileMultiSelection()
scheduleSave()
}

Expand All @@ -402,6 +496,7 @@ final class ClipboardStore {
deleteImageFile(item)
}
if let sel = selectedID, set.contains(sel) { selectFirst() }
reconcileMultiSelection()
scheduleSave()
}

Expand Down
20 changes: 19 additions & 1 deletion Sources/Pesty/UI/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ struct BarView: View {
PinboardTabs()
.layoutPriority(1)
Spacer(minLength: 8)
if store.multiSelectedIDs.count > 1 {
bulkDeleteButton
}
moreMenu
}
.padding(.horizontal, 18)
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 11 additions & 2 deletions Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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"
}
}
Loading