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
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user