WIP: show filtered result in clipboard

This commit is contained in:
Akshay Kolli
2026-06-30 03:28:50 -07:00
parent 582d317d28
commit a2404ad4f9
8 changed files with 159 additions and 14 deletions

View File

@@ -12,9 +12,10 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `Command + Option + V` toggles the clipboard panel - `Command + Option + V` toggles the clipboard panel
- `Command + ,` opens settings - `Command + ,` opens settings
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text - `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text
- `Command + G` shows a filtered result back in the full clipboard history
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images - Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Sort modes for recent, most used, images, links, text, files, audio, and pinned items
- Custom named collections for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips - Custom named collections for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
- Copy and paste actions with Accessibility permission fallback - Copy and paste actions with Accessibility permission fallback

View File

@@ -34,16 +34,17 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
1. Open the panel and confirm the search field is focused. 1. Open the panel and confirm the search field is focused.
2. Type a query and confirm results filter immediately. 2. Type a query and confirm results filter immediately.
3. Use arrow keys to move selection while the search field is focused. 3. Use arrow keys to move selection while the search field is focused.
4. Press `Esc` once with a non-empty search field and confirm search clears. 4. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent.
5. Press `Esc` again and confirm the panel closes. 5. Press `Esc` once with a non-empty search field and confirm search clears.
6. Reopen the panel, change sort segments, and confirm each segment updates results. 6. Press `Esc` again and confirm the panel closes.
7. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count. 7. Reopen the panel, change sort segments, and confirm each segment updates results.
8. Select another card and confirm its Collect button offers Client Work as a reusable destination. 8. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count.
9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists. 9. Select another card and confirm its Collect button offers Client Work as a reusable destination.
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 10. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
12. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected. 12. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
13. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 13. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected.
14. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
## Copy And Paste ## Copy And Paste

View File

@@ -21,6 +21,7 @@ enum ClipboardPanelShortcutAction: Equatable {
case pasteStackNext case pasteStackNext
case preview case preview
case reveal case reveal
case showInClipboard
case toggleStack case toggleStack
} }
@@ -407,6 +408,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
previewSelected() previewSelected()
case .reveal: case .reveal:
viewModel.revealSelected() viewModel.revealSelected()
case .showInClipboard:
panelView.showSelectedInClipboard()
case .toggleStack: case .toggleStack:
viewModel.toggleSelectedStackMembership() viewModel.toggleSelectedStackMembership()
} }
@@ -471,6 +474,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return .copy return .copy
case 31: case 31:
return .open return .open
case 5:
return .showInClipboard
case 16: case 16:
return .preview return .preview
case 15: case 15:

View File

@@ -638,7 +638,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
layout: layout, layout: layout,
collectionNames: collectionNames, collectionNames: collectionNames,
isStacked: viewModel.isItemStacked(at: index), isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount stackCount: viewModel.stackCount,
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard
) )
card.onSelect = { [weak self] selected in card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
@@ -672,6 +673,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
card.onClearStack = { [weak self] in card.onClearStack = { [weak self] in
self?.viewModel.clearStack() self?.viewModel.clearStack()
} }
card.onShowInClipboard = { [weak self] selected in
self?.showSelectedInClipboard(at: selected)
}
card.onEditText = { [weak self] selected in card.onEditText = { [weak self] selected in
self?.editText(at: selected) self?.editText(at: selected)
} }
@@ -821,7 +825,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") { if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") {
return .ready return .ready
} }
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") { if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
return .action return .action
} }
if lower.hasPrefix("error") || lower.contains("failed") { if lower.hasPrefix("error") || lower.contains("failed") {
@@ -1240,6 +1244,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
addSelectedClipToCollection() addSelectedClipToCollection()
} }
func debugShowFirstCardInClipboard() {
showSelectedInClipboard(at: 0)
}
var debugSearchFieldText: String {
searchField.stringValue
}
func debugDropFirstCard(onCollectionNamed collectionName: String) { func debugDropFirstCard(onCollectionNamed collectionName: String) {
guard let itemID = cardViews.first?.debugItemID else { return } guard let itemID = cardViews.first?.debugItemID else { return }
customCollectionButtons[collectionName]?.debugDropItem(itemID) customCollectionButtons[collectionName]?.debugDropItem(itemID)
@@ -1298,6 +1310,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
onSettings() onSettings()
} }
func showSelectedInClipboard() {
showSelectedInClipboard(at: viewModel.selectedIndex)
}
private func showSelectedInClipboard(at index: Int) {
viewModel.selectItem(at: index)
viewModel.showSelectedInClipboard()
searchField.stringValue = viewModel.searchText
}
@objc private func addSelectedClipToCollection() { @objc private func addSelectedClipToCollection() {
guard viewModel.selectedItem != nil, guard viewModel.selectedItem != nil,
let name = requestCollectionName() else { let name = requestCollectionName() else {
@@ -1598,6 +1620,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onPasteStackNext: () -> Void = {} var onPasteStackNext: () -> Void = {}
var onCopyStackNext: () -> Void = {} var onCopyStackNext: () -> Void = {}
var onClearStack: () -> Void = {} var onClearStack: () -> Void = {}
var onShowInClipboard: (Int) -> Void = { _ in }
var onEditText: (Int) -> Void = { _ in } var onEditText: (Int) -> Void = { _ in }
var onPreview: (Int) -> Void = { _ in } var onPreview: (Int) -> Void = { _ in }
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
@@ -1616,6 +1639,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private let itemIsPinned: Bool private let itemIsPinned: Bool
private let itemIsStacked: Bool private let itemIsStacked: Bool
private let stackCount: Int private let stackCount: Int
private let canShowInClipboard: Bool
private let itemSourceAppName: String? private let itemSourceAppName: String?
private let itemSourceAppBundleID: String? private let itemSourceAppBundleID: String?
private let itemCollectionName: String? private let itemCollectionName: String?
@@ -1639,7 +1663,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
layout: ClipboardItemCardLayout = .regular, layout: ClipboardItemCardLayout = .regular,
collectionNames: [String] = [], collectionNames: [String] = [],
isStacked: Bool = false, isStacked: Bool = false,
stackCount: Int = 0 stackCount: Int = 0,
canShowInClipboard: Bool = false
) { ) {
self.index = index self.index = index
self.itemID = item.id self.itemID = item.id
@@ -1648,6 +1673,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
self.itemIsPinned = item.isPinned self.itemIsPinned = item.isPinned
self.itemIsStacked = isStacked self.itemIsStacked = isStacked
self.stackCount = stackCount self.stackCount = stackCount
self.canShowInClipboard = canShowInClipboard
self.itemSourceAppName = Self.presentSourceText(item.sourceApp) self.itemSourceAppName = Self.presentSourceText(item.sourceApp)
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId) self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
self.itemCollectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) self.itemCollectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName)
@@ -1840,6 +1866,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu) addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu)
addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), to: menu) addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), to: menu)
} }
if canShowInClipboard {
addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu)
}
addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu) addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu)
if stackCount > 0 { if stackCount > 0 {
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu) addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
@@ -2138,6 +2167,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onClearStack() onClearStack()
} }
@objc private func showInClipboardFromMenu() {
onShowInClipboard(index)
}
@objc private func editTextFromMenu() { @objc private func editTextFromMenu() {
onEditText(index) onEditText(index)
} }

View File

@@ -125,6 +125,18 @@ final class ClipboardPanelViewModel {
return visibleItems[selectedIndex] return visibleItems[selectedIndex]
} }
var canShowSelectedInClipboard: Bool {
selectedItem != nil && canShowVisibleItemsInClipboard
}
var canShowVisibleItemsInClipboard: Bool {
!visibleItems.isEmpty
&& (!searchText.clipboardTrimmed.isEmpty
|| sortMode != .mostRecent
|| selectedCollectionName != nil
|| isStackFilterSelected)
}
var totalItemCount: Int { var totalItemCount: Int {
items.count items.count
} }
@@ -464,6 +476,30 @@ final class ClipboardPanelViewModel {
searchText = "" searchText = ""
} }
func showSelectedInClipboard() {
guard canShowSelectedInClipboard, let item = selectedItem else { return }
selectedItemID = item.id
if !searchText.isEmpty {
searchText = ""
}
if isStackFilterSelected {
isStackFilterSelected = false
}
if selectedCollectionName != nil {
selectedCollectionName = nil
}
if sortMode != .mostRecent {
sortMode = .mostRecent
}
if let index = visibleItems.firstIndex(where: { $0.id == item.id }) {
selectedIndex = index
selectedItemID = item.id
}
statusMessage = "Showing in Clipboard"
}
func recomputeVisibleItems() { func recomputeVisibleItems() {
pruneStackItems() pruneStackItems()
let previousSelection = selectedItemID let previousSelection = selectedItemID

View File

@@ -166,6 +166,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
func testCommandActionShortcutsMapToSelectedClipActions() { func testCommandActionShortcutsMapToSelectedClipActions() {
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal)

View File

@@ -256,6 +256,44 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.selectedItem?.payload, "needle note") XCTAssertEqual(viewModel.selectedItem?.payload, "needle note")
} }
func testShowSelectedInClipboardClearsFiltersAndKeepsHistoryPosition() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let older = makeTextItem("release needle", createdAt: Date(timeIntervalSince1970: 100))
let newer = makeTextItem("meeting note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(older)
store.upsert(newer)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["meeting note", "release needle"])
viewModel.selectItem(at: 1)
viewModel.assignSelected(to: "Client Work")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectCollection(named: "Client Work")
viewModel.searchText = "release"
XCTAssertTrue(viewModel.canShowSelectedInClipboard)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["release needle"])
viewModel.showSelectedInClipboard()
XCTAssertEqual(viewModel.searchText, "")
XCTAssertNil(viewModel.selectedCollectionName)
XCTAssertFalse(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.sortMode, .mostRecent)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["meeting note", "release needle"])
XCTAssertEqual(viewModel.selectedItem?.id, older.id)
XCTAssertEqual(viewModel.selectedIndex, 1)
XCTAssertFalse(viewModel.canShowSelectedInClipboard)
XCTAssertEqual(viewModel.statusMessage, "Showing in Clipboard")
}
func testSelectFirstItemSelectsFirstVisibleItem() { func testSelectFirstItemSelectsFirstVisibleItem() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -407,6 +407,36 @@ final class ClipboardPanelViewTests: XCTestCase {
) )
} }
func testFilteredCardsExposeShowInClipboardContextMenuAction() {
let fixture = makePanelFixture()
var release = makeTextItem("Release needle", store: fixture.store)
release.createdAt = Date(timeIntervalSince1970: 100)
release.lastUsedAt = release.createdAt
var meeting = makeTextItem("Meeting note", store: fixture.store)
meeting.createdAt = Date(timeIntervalSince1970: 200)
meeting.lastUsedAt = meeting.createdAt
fixture.store.upsert(release)
fixture.store.upsert(meeting)
drainMainQueue()
fixture.viewModel.searchText = "release"
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Show in Clipboard", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
fixture.view.debugShowFirstCardInClipboard()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugSearchFieldText, "")
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Meeting note", "Release needle"])
XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Release needle")
}
func testPreviewableCardsExposeQuickLookContextMenuAction() { func testPreviewableCardsExposeQuickLookContextMenuAction() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store)) fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))