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 + ,` opens settings
- `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
- 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
- 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

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.
2. Type a query and confirm results filter immediately.
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.
5. Press `Esc` again and confirm the panel closes.
6. Reopen the panel, change sort segments, and confirm each segment updates results.
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.
8. Select another card and confirm its Collect button offers Client Work as a reusable destination.
9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
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.
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.
13. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
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` once with a non-empty search field and confirm search clears.
6. Press `Esc` again and confirm the panel closes.
7. Reopen the panel, change sort segments, and confirm each segment updates results.
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 another card and confirm its Collect button offers Client Work as a reusable destination.
10. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
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. 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

View File

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

View File

@@ -638,7 +638,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
layout: layout,
collectionNames: collectionNames,
isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount
stackCount: viewModel.stackCount,
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard
)
card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected)
@@ -672,6 +673,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
card.onClearStack = { [weak self] in
self?.viewModel.clearStack()
}
card.onShowInClipboard = { [weak self] selected in
self?.showSelectedInClipboard(at: selected)
}
card.onEditText = { [weak self] selected in
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") {
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
}
if lower.hasPrefix("error") || lower.contains("failed") {
@@ -1240,6 +1244,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
addSelectedClipToCollection()
}
func debugShowFirstCardInClipboard() {
showSelectedInClipboard(at: 0)
}
var debugSearchFieldText: String {
searchField.stringValue
}
func debugDropFirstCard(onCollectionNamed collectionName: String) {
guard let itemID = cardViews.first?.debugItemID else { return }
customCollectionButtons[collectionName]?.debugDropItem(itemID)
@@ -1298,6 +1310,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
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() {
guard viewModel.selectedItem != nil,
let name = requestCollectionName() else {
@@ -1598,6 +1620,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onPasteStackNext: () -> Void = {}
var onCopyStackNext: () -> Void = {}
var onClearStack: () -> Void = {}
var onShowInClipboard: (Int) -> Void = { _ in }
var onEditText: (Int) -> Void = { _ in }
var onPreview: (Int) -> Void = { _ in }
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
@@ -1616,6 +1639,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private let itemIsPinned: Bool
private let itemIsStacked: Bool
private let stackCount: Int
private let canShowInClipboard: Bool
private let itemSourceAppName: String?
private let itemSourceAppBundleID: String?
private let itemCollectionName: String?
@@ -1639,7 +1663,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
layout: ClipboardItemCardLayout = .regular,
collectionNames: [String] = [],
isStacked: Bool = false,
stackCount: Int = 0
stackCount: Int = 0,
canShowInClipboard: Bool = false
) {
self.index = index
self.itemID = item.id
@@ -1648,6 +1673,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
self.itemIsPinned = item.isPinned
self.itemIsStacked = isStacked
self.stackCount = stackCount
self.canShowInClipboard = canShowInClipboard
self.itemSourceAppName = Self.presentSourceText(item.sourceApp)
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
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("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)
if stackCount > 0 {
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
@@ -2138,6 +2167,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onClearStack()
}
@objc private func showInClipboardFromMenu() {
onShowInClipboard(index)
}
@objc private func editTextFromMenu() {
onEditText(index)
}

View File

@@ -125,6 +125,18 @@ final class ClipboardPanelViewModel {
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 {
items.count
}
@@ -464,6 +476,30 @@ final class ClipboardPanelViewModel {
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() {
pruneStackItems()
let previousSelection = selectedItemID

View File

@@ -166,6 +166,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
func testCommandActionShortcutsMapToSelectedClipActions() {
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: 31, modifiers: .command), .open)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal)

View File

@@ -256,6 +256,44 @@ final class ClipboardPanelViewModelTests: XCTestCase {
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() {
let settings = makeSettings()
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() {
let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))