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 - 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, 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 - 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 - Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning - 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 - 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. 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. 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. 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. 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. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
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. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 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 ## Copy And Paste

View File

@@ -114,9 +114,13 @@ struct ClipboardItem {
var sourceAppBundleId: String? var sourceAppBundleId: String?
var ocrText: String? var ocrText: String?
var collectionName: String? var collectionName: String?
var customTitle: String?
var searchableText: String { var searchableText: String {
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased() var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
if let customTitle {
text += " " + customTitle.lowercased()
}
if let sourceApp { if let sourceApp {
text += " " + sourceApp.lowercased() text += " " + sourceApp.lowercased()
} }
@@ -160,7 +164,8 @@ struct ClipboardItem {
isPinned: Bool = false, isPinned: Bool = false,
sourceAppBundleId: String? = nil, sourceAppBundleId: String? = nil,
ocrText: String? = nil, ocrText: String? = nil,
collectionName: String? = nil collectionName: String? = nil,
customTitle: String? = nil
) { ) {
self.id = id self.id = id
self.kind = kind self.kind = kind
@@ -177,5 +182,16 @@ struct ClipboardItem {
self.sourceAppBundleId = sourceAppBundleId self.sourceAppBundleId = sourceAppBundleId
self.ocrText = ocrText self.ocrText = ocrText
self.collectionName = collectionName 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])) 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 @discardableResult
func updateText(_ id: UUID, text: String) -> Bool { func updateText(_ id: UUID, text: String) -> Bool {
guard !text.isEmpty, guard !text.isEmpty,
@@ -209,6 +215,7 @@ final class ClipboardStore {
} }
existing.sourceApp = incoming.sourceApp existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId existing.sourceAppBundleId = incoming.sourceAppBundleId
existing.customTitle = incoming.customTitle ?? existing.customTitle
items.insert(existing, at: 0) items.insert(existing, at: 0)
normalizeHistoryLength() normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.kind == .image) persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
@@ -227,6 +234,7 @@ final class ClipboardStore {
existing.kind = incoming.kind existing.kind = incoming.kind
existing.sourceApp = incoming.sourceApp existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId existing.sourceAppBundleId = incoming.sourceAppBundleId
existing.customTitle = incoming.customTitle ?? existing.customTitle
if incoming.kind == .image || incoming.kind == .url { if incoming.kind == .image || incoming.kind == .url {
existing.imagePath = incoming.imagePath existing.imagePath = incoming.imagePath
@@ -332,7 +340,8 @@ final class ClipboardStore {
thumbnail_path TEXT, thumbnail_path TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0, is_pinned INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT, ocr_text TEXT,
collection_name TEXT collection_name TEXT,
custom_title TEXT
); );
""" """
@@ -347,6 +356,7 @@ final class ClipboardStore {
_ = execute(createTable) _ = execute(createTable)
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;") _ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
_ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;")
_ = execute(createIndexes) _ = execute(createIndexes)
} }
@@ -411,7 +421,8 @@ final class ClipboardStore {
isPinned: row["isPinned"] as? Bool ?? false, isPinned: row["isPinned"] as? Bool ?? false,
sourceAppBundleId: row["sourceAppBundleId"] as? String, sourceAppBundleId: row["sourceAppBundleId"] as? String,
ocrText: row["ocrText"] 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 SELECT
id, kind, display_text, payload, payload_hash, created_at, id, kind, display_text, payload, payload_hash, created_at,
last_used_at, use_count, source_app, source_app_bundle_id, 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 FROM clipboard_items
ORDER BY created_at DESC, last_used_at DESC 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 isPinned = sqlite3_column_int(statement, 12) != 0
let ocrTextValue = stringValue(13) let ocrTextValue = stringValue(13)
let collectionNameValue = stringValue(14) let collectionNameValue = stringValue(14)
let customTitleValue = stringValue(15)
needsEncryptionMigration = needsEncryptionMigration needsEncryptionMigration = needsEncryptionMigration
|| sourceAppValue.migrationNeeded || sourceAppValue.migrationNeeded
@@ -612,6 +625,7 @@ final class ClipboardStore {
|| thumbnailPathValue.migrationNeeded || thumbnailPathValue.migrationNeeded
|| ocrTextValue.migrationNeeded || ocrTextValue.migrationNeeded
|| collectionNameValue.migrationNeeded || collectionNameValue.migrationNeeded
|| customTitleValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure hadDecodeFailure = hadDecodeFailure
|| sourceAppValue.decodeFailed || sourceAppValue.decodeFailed
|| sourceAppBundleIdValue.decodeFailed || sourceAppBundleIdValue.decodeFailed
@@ -619,6 +633,7 @@ final class ClipboardStore {
|| thumbnailPathValue.decodeFailed || thumbnailPathValue.decodeFailed
|| ocrTextValue.decodeFailed || ocrTextValue.decodeFailed
|| collectionNameValue.decodeFailed || collectionNameValue.decodeFailed
|| customTitleValue.decodeFailed
loaded.append( loaded.append(
ClipboardItem( ClipboardItem(
@@ -636,7 +651,8 @@ final class ClipboardStore {
isPinned: isPinned, isPinned: isPinned,
sourceAppBundleId: sourceAppBundleIdValue.value, sourceAppBundleId: sourceAppBundleIdValue.value,
ocrText: ocrTextValue.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, id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app, created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text, source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name collection_name, custom_title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""" """
switch mutation { switch mutation {
@@ -768,8 +784,8 @@ final class ClipboardStore {
id, kind, display_text, payload, payload_hash, id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app, created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text, source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name collection_name, custom_title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""" """
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
@@ -853,5 +869,6 @@ final class ClipboardStore {
sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0) sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0)
bindText(statement, 14, encryptionService.protect(item.ocrText)) bindText(statement, 14, encryptionService.protect(item.ocrText))
bindText(statement, 15, encryptionService.protect(item.collectionName)) 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 card.onShowInClipboard = { [weak self] selected in
self?.showSelectedInClipboard(at: selected) self?.showSelectedInClipboard(at: selected)
} }
card.onRename = { [weak self] selected in
self?.renameClip(at: selected)
}
card.onEditText = { [weak self] selected in card.onEditText = { [weak self] selected in
self?.editText(at: selected) 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") { 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("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 return .action
} }
if lower.hasPrefix("error") || lower.contains("failed") { if lower.hasPrefix("error") || lower.contains("failed") {
@@ -960,6 +963,26 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.updateSelectedText(to: textView.string) 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 { private func emptyStateView() -> NSView {
let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width) let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width)
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight)) 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) viewModel.deleteCollection(named: collectionName)
} }
func debugRenameFirstCard(to title: String) {
viewModel.selectItem(at: 0)
viewModel.updateSelectedTitle(to: title)
}
func debugShowFirstCardInClipboard() { func debugShowFirstCardInClipboard() {
showSelectedInClipboard(at: 0) showSelectedInClipboard(at: 0)
} }
@@ -1859,6 +1887,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onCopyStackNext: () -> Void = {} var onCopyStackNext: () -> Void = {}
var onClearStack: () -> Void = {} var onClearStack: () -> Void = {}
var onShowInClipboard: (Int) -> Void = { _ in } var onShowInClipboard: (Int) -> Void = { _ in }
var onRename: (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 [] }
@@ -2129,6 +2158,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
if canShowInClipboard { if canShowInClipboard {
addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu) 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) 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)
@@ -2431,6 +2461,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onShowInClipboard(index) onShowInClipboard(index)
} }
@objc private func renameFromMenu() {
onRename(index)
}
@objc private func editTextFromMenu() { @objc private func editTextFromMenu() {
onEditText(index) onEditText(index)
} }
@@ -3226,6 +3260,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
private func titleText(for item: ClipboardItem) -> String { private func titleText(for item: ClipboardItem) -> String {
if let customTitle = item.customTitle?.clipboardTrimmed, !customTitle.isEmpty {
return customTitle
}
switch item.kind { switch item.kind {
case .url: case .url:
return linkTitle(for: item) return linkTitle(for: item)

View File

@@ -358,6 +358,24 @@ final class ClipboardPanelViewModel {
return item.payload 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) { func updateSelectedText(to text: String) {
guard let item = selectedItem, item.kind == .text else { return } guard let item = selectedItem, item.kind == .text else { return }
let trimmed = text.clipboardTrimmed let trimmed = text.clipboardTrimmed

View File

@@ -769,6 +769,43 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.visibleItems.isEmpty) 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() { func testUpdateSelectedTextRejectsEmptyAndNonTextSelections() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -438,7 +438,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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( XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles, fixture.view.debugFirstCardCollectionMenuTitles,
@@ -472,7 +472,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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() fixture.view.debugShowFirstCardInClipboard()
@@ -492,7 +492,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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( XCTAssertEqual(
fixture.view.debugFirstCardCaptureRuleMenuTitles, fixture.view.debugFirstCardCaptureRuleMenuTitles,
@@ -512,7 +512,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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"]) 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"]) 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 { func testMultipleFileCardsUseCountAndSharedLocation() throws {
let fixture = makePanelFixture() let fixture = makePanelFixture()
let directory = makeTempDirectory() let directory = makeTempDirectory()

View File

@@ -138,6 +138,32 @@ final class ClipboardStoreTests: XCTestCase {
XCTAssertNil(cleared.items.first?.collectionName) 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() { func testUpdateTextPersistsAcrossReloadAndPreservesMetadata() {
let settings = makeSettings(maxHistory: 50) let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings) let store = makeStore(settings: settings)