diff --git a/README.md b/README.md index d99ee87..8e5dbc6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index f39cfb1..dadef65 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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 diff --git a/sources/clipbored/models/ClipboardItem.swift b/sources/clipbored/models/ClipboardItem.swift index 1eb4e83..066a39a 100644 --- a/sources/clipbored/models/ClipboardItem.swift +++ b/sources/clipbored/models/ClipboardItem.swift @@ -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)) } } diff --git a/sources/clipbored/services/ClipboardStore.swift b/sources/clipbored/services/ClipboardStore.swift index 15b9c50..58b5598 100644 --- a/sources/clipbored/services/ClipboardStore.swift +++ b/sources/clipbored/services/ClipboardStore.swift @@ -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)) } } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 9743983..6d3dc33 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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) diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 199a071..c4f44ee 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -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 diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 2604bc5..40668b1 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -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() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index ba0beeb..06a122d 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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() diff --git a/tests/clipboredtests/ClipboardStoreTests.swift b/tests/clipboredtests/ClipboardStoreTests.swift index 38dcc78..cb28ecc 100644 --- a/tests/clipboredtests/ClipboardStoreTests.swift +++ b/tests/clipboredtests/ClipboardStoreTests.swift @@ -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)