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

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

View File

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

View File

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

View File

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