WIP: add searchable clip titles

This commit is contained in:
Akshay Kolli
2026-06-30 04:10:12 -07:00
parent 3e38514387
commit a0130752eb
9 changed files with 199 additions and 18 deletions

View File

@@ -19,6 +19,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- 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, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
- Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type

View File

@@ -42,10 +42,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
11. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
12. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
13. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
14. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
15. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
12. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
13. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
14. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
15. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
16. 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

@@ -114,9 +114,13 @@ struct ClipboardItem {
var sourceAppBundleId: String?
var ocrText: String?
var collectionName: String?
var customTitle: String?
var searchableText: String {
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
if let customTitle {
text += " " + customTitle.lowercased()
}
if let sourceApp {
text += " " + sourceApp.lowercased()
}
@@ -160,7 +164,8 @@ struct ClipboardItem {
isPinned: Bool = false,
sourceAppBundleId: String? = nil,
ocrText: String? = nil,
collectionName: String? = nil
collectionName: String? = nil,
customTitle: String? = nil
) {
self.id = id
self.kind = kind
@@ -177,5 +182,16 @@ struct ClipboardItem {
self.sourceAppBundleId = sourceAppBundleId
self.ocrText = ocrText
self.collectionName = collectionName
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
}
static func normalizedCustomTitle(_ value: String?) -> String? {
guard let value else { return nil }
let title = value
.split { $0.isWhitespace }
.joined(separator: " ")
.clipboardTrimmed
guard !title.isEmpty else { return nil }
return String(title.prefix(80))
}
}

View File

@@ -101,6 +101,12 @@ final class ClipboardStore {
persistAsync(.upsert(items[index]))
}
func setCustomTitle(_ id: UUID, title: String?) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].customTitle = ClipboardItem.normalizedCustomTitle(title)
persistAsync(.upsert(items[index]))
}
@discardableResult
func updateText(_ id: UUID, text: String) -> Bool {
guard !text.isEmpty,
@@ -209,6 +215,7 @@ final class ClipboardStore {
}
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
existing.customTitle = incoming.customTitle ?? existing.customTitle
items.insert(existing, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
@@ -227,6 +234,7 @@ final class ClipboardStore {
existing.kind = incoming.kind
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
existing.customTitle = incoming.customTitle ?? existing.customTitle
if incoming.kind == .image || incoming.kind == .url {
existing.imagePath = incoming.imagePath
@@ -332,7 +340,8 @@ final class ClipboardStore {
thumbnail_path TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
collection_name TEXT
collection_name TEXT,
custom_title TEXT
);
"""
@@ -347,6 +356,7 @@ final class ClipboardStore {
_ = execute(createTable)
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
_ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;")
_ = execute(createIndexes)
}
@@ -411,7 +421,8 @@ final class ClipboardStore {
isPinned: row["isPinned"] as? Bool ?? false,
sourceAppBundleId: row["sourceAppBundleId"] as? String,
ocrText: row["ocrText"] as? String,
collectionName: row["collectionName"] as? String
collectionName: row["collectionName"] as? String,
customTitle: row["customTitle"] as? String
)
}
@@ -523,7 +534,8 @@ final class ClipboardStore {
SELECT
id, kind, display_text, payload, payload_hash, created_at,
last_used_at, use_count, source_app, source_app_bundle_id,
image_path, thumbnail_path, is_pinned, ocr_text, collection_name
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
custom_title
FROM clipboard_items
ORDER BY created_at DESC, last_used_at DESC
"""
@@ -604,6 +616,7 @@ final class ClipboardStore {
let isPinned = sqlite3_column_int(statement, 12) != 0
let ocrTextValue = stringValue(13)
let collectionNameValue = stringValue(14)
let customTitleValue = stringValue(15)
needsEncryptionMigration = needsEncryptionMigration
|| sourceAppValue.migrationNeeded
@@ -612,6 +625,7 @@ final class ClipboardStore {
|| thumbnailPathValue.migrationNeeded
|| ocrTextValue.migrationNeeded
|| collectionNameValue.migrationNeeded
|| customTitleValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure
|| sourceAppValue.decodeFailed
|| sourceAppBundleIdValue.decodeFailed
@@ -619,6 +633,7 @@ final class ClipboardStore {
|| thumbnailPathValue.decodeFailed
|| ocrTextValue.decodeFailed
|| collectionNameValue.decodeFailed
|| customTitleValue.decodeFailed
loaded.append(
ClipboardItem(
@@ -636,7 +651,8 @@ final class ClipboardStore {
isPinned: isPinned,
sourceAppBundleId: sourceAppBundleIdValue.value,
ocrText: ocrTextValue.value,
collectionName: collectionNameValue.value
collectionName: collectionNameValue.value,
customTitle: customTitleValue.value
)
)
}
@@ -668,8 +684,8 @@ final class ClipboardStore {
id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
collection_name, custom_title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
switch mutation {
@@ -768,8 +784,8 @@ final class ClipboardStore {
id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
collection_name, custom_title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
@@ -853,5 +869,6 @@ final class ClipboardStore {
sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0)
bindText(statement, 14, encryptionService.protect(item.ocrText))
bindText(statement, 15, encryptionService.protect(item.collectionName))
bindText(statement, 16, encryptionService.protect(item.customTitle))
}
}

View File

@@ -754,6 +754,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
card.onShowInClipboard = { [weak self] selected in
self?.showSelectedInClipboard(at: selected)
}
card.onRename = { [weak self] selected in
self?.renameClip(at: selected)
}
card.onEditText = { [weak self] selected in
self?.editText(at: selected)
}
@@ -902,7 +905,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("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("renamed") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
return .action
}
if lower.hasPrefix("error") || lower.contains("failed") {
@@ -960,6 +963,26 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.updateSelectedText(to: textView.string)
}
private func renameClip(at index: Int) {
viewModel.selectItem(at: index)
guard let currentTitle = viewModel.editableTitleForSelected() else { return }
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
input.placeholderString = "Clip title"
input.stringValue = currentTitle
let alert = NSAlert()
alert.messageText = "Rename Clip"
alert.informativeText = "Give this clip a searchable title. Leave it blank to clear the title."
alert.accessoryView = input
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
viewModel.updateSelectedTitle(to: input.stringValue)
}
private func emptyStateView() -> NSView {
let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width)
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight))
@@ -1365,6 +1388,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.deleteCollection(named: collectionName)
}
func debugRenameFirstCard(to title: String) {
viewModel.selectItem(at: 0)
viewModel.updateSelectedTitle(to: title)
}
func debugShowFirstCardInClipboard() {
showSelectedInClipboard(at: 0)
}
@@ -1859,6 +1887,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onCopyStackNext: () -> Void = {}
var onClearStack: () -> Void = {}
var onShowInClipboard: (Int) -> Void = { _ in }
var onRename: (Int) -> Void = { _ in }
var onEditText: (Int) -> Void = { _ in }
var onPreview: (Int) -> Void = { _ in }
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
@@ -2129,6 +2158,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
if canShowInClipboard {
addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu)
}
addMenuItem("Rename...", action: #selector(renameFromMenu), 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)
@@ -2431,6 +2461,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onShowInClipboard(index)
}
@objc private func renameFromMenu() {
onRename(index)
}
@objc private func editTextFromMenu() {
onEditText(index)
}
@@ -3226,6 +3260,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
}
private func titleText(for item: ClipboardItem) -> String {
if let customTitle = item.customTitle?.clipboardTrimmed, !customTitle.isEmpty {
return customTitle
}
switch item.kind {
case .url:
return linkTitle(for: item)

View File

@@ -358,6 +358,24 @@ final class ClipboardPanelViewModel {
return item.payload
}
func editableTitleForSelected() -> String? {
guard let item = selectedItem else { return nil }
return item.customTitle ?? ""
}
func updateSelectedTitle(to title: String) {
guard let item = selectedItem else { return }
let normalizedTitle = ClipboardItem.normalizedCustomTitle(title)
guard item.customTitle != normalizedTitle else {
statusMessage = "No changes"
return
}
selectedItemID = item.id
store.setCustomTitle(item.id, title: normalizedTitle)
statusMessage = normalizedTitle == nil ? "Cleared clip title" : "Renamed clip"
}
func updateSelectedText(to text: String) {
guard let item = selectedItem, item.kind == .text else { return }
let trimmed = text.clipboardTrimmed

View File

@@ -769,6 +769,43 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.visibleItems.isEmpty)
}
func testUpdateSelectedTitleRefreshesSearchWithoutChangingPayload() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 0)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.editableTitleForSelected(), "")
viewModel.updateSelectedTitle(to: " Launch Brief ")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.statusMessage, "Renamed clip")
XCTAssertEqual(viewModel.selectedItem?.id, item.id)
XCTAssertEqual(viewModel.selectedItem?.customTitle, "Launch Brief")
XCTAssertEqual(viewModel.selectedItem?.payload, item.payload)
XCTAssertEqual(viewModel.selectedItem?.payloadHash, item.payloadHash)
viewModel.searchText = "launch"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.searchText = "missing"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.searchText = "brief"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.updateSelectedTitle(to: " ")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 0)
XCTAssertEqual(viewModel.statusMessage, "Cleared clip title")
XCTAssertTrue(store.items.contains { $0.id == item.id && $0.customTitle == nil && $0.payload == item.payload })
}
func testUpdateSelectedTextRejectsEmptyAndNonTextSelections() {
let settings = makeSettings()
let cacheService = makeCacheService()

View File

@@ -438,7 +438,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Rename...", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
@@ -472,7 +472,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Show in Clipboard", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
fixture.view.debugShowFirstCardInClipboard()
@@ -492,7 +492,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCaptureRuleMenuTitles,
@@ -512,7 +512,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Rename...", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Remove from Stack", "Edit", "Delete"])
}
@@ -671,6 +671,33 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
}
func testRenamedClipsUseCustomTitleInCardsAndSearch() {
let fixture = makePanelFixture()
let fileURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Documents")
.appendingPathComponent("Project Plan.pdf")
let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Rename..."))
fixture.view.debugRenameFirstCard(to: " Client Launch Brief ")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Client Launch Brief"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Client Launch Brief|~/Documents|PDF"])
XCTAssertEqual(fixture.view.debugStatusText, "Renamed clip")
XCTAssertEqual(fixture.view.debugStatusTone, "action")
fixture.viewModel.searchText = "launch"
drainMainQueue()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id])
}
func testMultipleFileCardsUseCountAndSharedLocation() throws {
let fixture = makePanelFixture()
let directory = makeTempDirectory()

View File

@@ -138,6 +138,32 @@ final class ClipboardStoreTests: XCTestCase {
XCTAssertNil(cleared.items.first?.collectionName)
}
func testSetCustomTitlePersistsAcrossReloadAndClears() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
store.setCustomTitle(itemID, title: " Launch Brief ")
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.customTitle, "Launch Brief")
XCTAssertEqual(restored.items.first?.payload, "alpha")
let restoredID = try! XCTUnwrap(restored.items.first?.id)
restored.setCustomTitle(restoredID, title: " ")
restored.flushPersistenceForTesting()
let cleared = makeStore(settings: settings)
cleared.flushPersistenceForTesting()
XCTAssertNil(cleared.items.first?.customTitle)
XCTAssertEqual(cleared.items.first?.payload, "alpha")
}
func testUpdateTextPersistsAcrossReloadAndPreservesMetadata() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)