WIP: add text clip editing
This commit is contained in:
@@ -101,6 +101,22 @@ final class ClipboardStore {
|
||||
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) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
let removed = items.remove(at: index)
|
||||
|
||||
@@ -528,6 +528,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
self?.viewModel.selectItem(at: selected)
|
||||
self?.viewModel.copySelected()
|
||||
}
|
||||
card.onEditText = { [weak self] selected in
|
||||
self?.editText(at: selected)
|
||||
}
|
||||
card.onPreview = { [weak self] selected in
|
||||
self?.viewModel.selectItem(at: selected)
|
||||
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") {
|
||||
return .ready
|
||||
}
|
||||
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") {
|
||||
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") {
|
||||
return .action
|
||||
}
|
||||
if lower.hasPrefix("error") || lower.contains("failed") {
|
||||
@@ -694,6 +697,36 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
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 {
|
||||
let width = max(760, scrollView.contentView.bounds.width)
|
||||
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 onPaste: (Int) -> Void = { _ in }
|
||||
var onCopy: (Int) -> Void = { _ in }
|
||||
var onEditText: (Int) -> Void = { _ in }
|
||||
var onPreview: (Int) -> Void = { _ in }
|
||||
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
|
||||
var onOpen: (Int) -> Void = { _ in }
|
||||
@@ -1457,6 +1491,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
menu.autoenablesItems = false
|
||||
addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu)
|
||||
addMenuItem("Copy", action: #selector(copyFromMenu), to: menu)
|
||||
if canEditText {
|
||||
addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu)
|
||||
}
|
||||
if canPreview {
|
||||
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 {
|
||||
switch itemKind {
|
||||
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(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
|
||||
]
|
||||
if canEditText {
|
||||
actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu)))
|
||||
}
|
||||
if canPreview {
|
||||
actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu)))
|
||||
}
|
||||
@@ -1650,6 +1694,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
onCopy(index)
|
||||
}
|
||||
|
||||
@objc private func editTextFromMenu() {
|
||||
onEditText(index)
|
||||
}
|
||||
|
||||
@objc private func previewFromMenu() {
|
||||
onPreview(index)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,36 @@ final class ClipboardPanelViewModel {
|
||||
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? {
|
||||
guard let item = selectedItem else { return nil }
|
||||
return previewURL(for: item)
|
||||
|
||||
@@ -271,6 +271,57 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
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() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
|
||||
@@ -69,6 +69,18 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
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() {
|
||||
let fixture = makePanelFixture()
|
||||
|
||||
@@ -172,8 +184,8 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Delete"])
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 126)
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Edit", "Delete"])
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 154)
|
||||
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
|
||||
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
|
||||
|
||||
@@ -321,7 +333,7 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
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(
|
||||
fixture.view.debugFirstCardCollectionMenuTitles,
|
||||
|
||||
@@ -138,6 +138,50 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
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 {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let itemID = UUID()
|
||||
|
||||
Reference in New Issue
Block a user