WIP: add searchable clip titles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user