WIP: add text clip editing

This commit is contained in:
Akshay Kolli
2026-06-30 01:56:40 -07:00
parent 099126d3a2
commit 0567c62310
6 changed files with 205 additions and 4 deletions

View File

@@ -101,6 +101,22 @@ final class ClipboardStore {
persistAsync(.upsert(items[index])) persistAsync(.upsert(items[index]))
} }
@discardableResult
func updateText(_ id: UUID, text: String) -> Bool {
guard !text.isEmpty,
let index = items.firstIndex(where: { $0.id == id }),
items[index].kind == .text else {
return false
}
items[index].displayText = text
items[index].payload = text
items[index].payloadHash = hashString(text)
items[index].ocrText = nil
persistAsync(.upsert(items[index]))
return true
}
func remove(_ id: UUID) { func remove(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return } guard let index = items.firstIndex(where: { $0.id == id }) else { return }
let removed = items.remove(at: index) let removed = items.remove(at: index)

View File

@@ -528,6 +528,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
self?.viewModel.copySelected() self?.viewModel.copySelected()
} }
card.onEditText = { [weak self] selected in
self?.editText(at: selected)
}
card.onPreview = { [weak self] selected in card.onPreview = { [weak self] selected in
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
self?.onPreview() self?.onPreview()
@@ -666,7 +669,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") { if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") {
return .action return .action
} }
if lower.hasPrefix("error") || lower.contains("failed") { if lower.hasPrefix("error") || lower.contains("failed") {
@@ -694,6 +697,36 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
statusResultCountLabel.toolTip = text statusResultCountLabel.toolTip = text
} }
private func editText(at index: Int) {
viewModel.selectItem(at: index)
guard let currentText = viewModel.editableTextForSelected() else { return }
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 460, height: 180))
textView.string = currentText
textView.font = .systemFont(ofSize: 13)
textView.isRichText = false
textView.allowsUndo = true
textView.textContainerInset = NSSize(width: 10, height: 10)
textView.usesAdaptiveColorMappingForDarkAppearance = true
let scrollView = NSScrollView(frame: textView.frame)
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .bezelBorder
scrollView.documentView = textView
let alert = NSAlert()
alert.messageText = "Edit Text"
alert.accessoryView = scrollView
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = textView
guard alert.runModal() == .alertFirstButtonReturn else { return }
viewModel.updateSelectedText(to: textView.string)
}
private func emptyStateView() -> NSView { private func emptyStateView() -> NSView {
let width = max(760, scrollView.contentView.bounds.width) let width = max(760, scrollView.contentView.bounds.width)
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: Metrics.cardRailHeight)) let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: Metrics.cardRailHeight))
@@ -1275,6 +1308,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onSelect: (Int) -> Void = { _ in } var onSelect: (Int) -> Void = { _ in }
var onPaste: (Int) -> Void = { _ in } var onPaste: (Int) -> Void = { _ in }
var onCopy: (Int) -> Void = { _ in } var onCopy: (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 [] }
var onOpen: (Int) -> Void = { _ in } var onOpen: (Int) -> Void = { _ in }
@@ -1457,6 +1491,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
menu.autoenablesItems = false menu.autoenablesItems = false
addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu) addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu)
addMenuItem("Copy", action: #selector(copyFromMenu), to: menu) addMenuItem("Copy", action: #selector(copyFromMenu), to: menu)
if canEditText {
addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu)
}
if canPreview { if canPreview {
addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu) addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu)
} }
@@ -1543,6 +1580,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
} }
private var canEditText: Bool {
itemKind == .text
}
private var canReveal: Bool { private var canReveal: Bool {
switch itemKind { switch itemKind {
case .file, .image, .pdf, .audio: case .file, .image, .pdf, .audio:
@@ -1577,6 +1618,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)), cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)),
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu)) cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
] ]
if canEditText {
actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu)))
}
if canPreview { if canPreview {
actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu))) actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu)))
} }
@@ -1650,6 +1694,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onCopy(index) onCopy(index)
} }
@objc private func editTextFromMenu() {
onEditText(index)
}
@objc private func previewFromMenu() { @objc private func previewFromMenu() {
onPreview(index) onPreview(index)
} }

View File

@@ -168,6 +168,36 @@ final class ClipboardPanelViewModel {
return pasteService.pasteboardWriters(for: visibleItems[index]) return pasteService.pasteboardWriters(for: visibleItems[index])
} }
func editableTextForSelected() -> String? {
guard let item = selectedItem, item.kind == .text else { return nil }
return item.payload
}
func editableTextForItem(at index: Int) -> String? {
guard index >= 0 && index < visibleItems.count else { return nil }
let item = visibleItems[index]
guard item.kind == .text else { return nil }
return item.payload
}
func updateSelectedText(to text: String) {
guard let item = selectedItem, item.kind == .text else { return }
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else {
statusMessage = "Text clip cannot be empty"
return
}
guard item.payload != text else {
statusMessage = "No changes"
return
}
selectedItemID = item.id
if store.updateText(item.id, text: text) {
statusMessage = "Updated text clip"
}
}
func previewURLForSelected() -> URL? { func previewURLForSelected() -> URL? {
guard let item = selectedItem else { return nil } guard let item = selectedItem else { return nil }
return previewURL(for: item) return previewURL(for: item)

View File

@@ -271,6 +271,57 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload) XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload)
} }
func testUpdateSelectedTextRefreshesVisibleItemAndSearch() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("draft meeting note", createdAt: Date(timeIntervalSince1970: 100))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
viewModel.updateSelectedText(to: "final launch note")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.statusMessage, "Updated text clip")
XCTAssertEqual(viewModel.selectedItem?.id, item.id)
XCTAssertEqual(viewModel.selectedItem?.displayText, "final launch note")
XCTAssertEqual(viewModel.selectedItem?.payload, "final launch note")
viewModel.searchText = "launch"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["final launch note"])
viewModel.searchText = "draft"
XCTAssertTrue(viewModel.visibleItems.isEmpty)
}
func testUpdateSelectedTextRejectsEmptyAndNonTextSelections() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let text = makeTextItem("editable note", createdAt: Date(timeIntervalSince1970: 100))
let file = makeMissingFileItem(useCount: 0)
store.upsert(text)
store.upsert(file)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertNil(viewModel.editableTextForItem(at: 0))
viewModel.selectItem(at: 0)
viewModel.updateSelectedText(to: "should not apply")
XCTAssertEqual(store.items.first?.payload, file.payload)
viewModel.selectItem(at: 1)
viewModel.updateSelectedText(to: " \n")
XCTAssertEqual(viewModel.statusMessage, "Text clip cannot be empty")
XCTAssertEqual(store.items.last?.payload, "editable note")
}
func testFailedCopyDoesNotMarkItemUsed() { func testFailedCopyDoesNotMarkItemUsed() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -69,6 +69,18 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste")) XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste"))
} }
func testEditedTextStatusUsesActionTone() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Editable footer item", store: fixture.store))
drainMainQueue()
fixture.viewModel.updateSelectedText(to: "Edited footer item")
drainMainQueue()
XCTAssertEqual(fixture.view.debugStatusText, "Updated text clip")
XCTAssertEqual(fixture.view.debugStatusTone, "action")
}
func testSkippedCaptureStatusUsesWarningTone() { func testSkippedCaptureStatusUsesWarningTone() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
@@ -172,8 +184,8 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
drainMainQueue() drainMainQueue()
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Delete"]) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Edit", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 126) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 154)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
@@ -321,7 +333,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] ["Paste", "Copy", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"]
) )
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles, fixture.view.debugFirstCardCollectionMenuTitles,

View File

@@ -138,6 +138,50 @@ final class ClipboardStoreTests: XCTestCase {
XCTAssertNil(cleared.items.first?.collectionName) XCTAssertNil(cleared.items.first?.collectionName)
} }
func testUpdateTextPersistsAcrossReloadAndPreservesMetadata() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
var item = makeItem("alpha", displayText: "Alpha", created: Date(timeIntervalSince1970: 100))
item.isPinned = true
item.collectionName = "Important Notes"
item.sourceApp = "Notes"
item.useCount = 4
store.upsert(item)
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
XCTAssertTrue(store.updateText(itemID, text: "Edited alpha"))
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
let restoredItem = try! XCTUnwrap(restored.items.first)
XCTAssertEqual(restoredItem.id, itemID)
XCTAssertEqual(restoredItem.kind, .text)
XCTAssertEqual(restoredItem.displayText, "Edited alpha")
XCTAssertEqual(restoredItem.payload, "Edited alpha")
XCTAssertEqual(restoredItem.payloadHash, restored.hashString("Edited alpha"))
XCTAssertEqual(restoredItem.isPinned, true)
XCTAssertEqual(restoredItem.collectionName, "Important Notes")
XCTAssertEqual(restoredItem.sourceApp, "Notes")
XCTAssertEqual(restoredItem.useCount, 4)
}
func testUpdateTextRejectsNonTextItems() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let pdf = makePDFItem(path: "/tmp/report.pdf", hash: "pdf-hash", created: Date(timeIntervalSince1970: 100))
store.upsert(pdf)
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
XCTAssertFalse(store.updateText(itemID, text: "Edited"))
XCTAssertEqual(store.items.first?.payload, "/tmp/report.pdf")
XCTAssertEqual(store.items.first?.payloadHash, "pdf-hash")
}
func testLegacyJSONHistoryMigratesToSQLite() throws { func testLegacyJSONHistoryMigratesToSQLite() throws {
let settings = makeSettings(maxHistory: 50) let settings = makeSettings(maxHistory: 50)
let itemID = UUID() let itemID = UUID()