From a2404ad4f93275ede5a8d7f59b9de017d04af0a9 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 03:28:50 -0700 Subject: [PATCH] WIP: show filtered result in clipboard --- README.md | 3 +- docs/SMOKE_TEST.md | 21 +++++----- .../views/ClipboardPanelController.swift | 5 +++ .../clipbored/views/ClipboardPanelView.swift | 39 +++++++++++++++++-- .../views/ClipboardPanelViewModel.swift | 36 +++++++++++++++++ .../ClipboardPanelControllerTests.swift | 1 + .../ClipboardPanelViewModelTests.swift | 38 ++++++++++++++++++ .../ClipboardPanelViewTests.swift | 30 ++++++++++++++ 8 files changed, 159 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 890f403..9d28e24 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index fbd2561..c9a2f60 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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 diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 0c09fb6..15a743b 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -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: diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 4a9635c..55e4650 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index f4d5e76..a2d203a 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -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 diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index b2d4457..fb78fcd 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -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) diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 63bce1a..49613dd 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -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() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 0c41188..c001f3b 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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))