diff --git a/sources/clipbored/services/ClipboardStore.swift b/sources/clipbored/services/ClipboardStore.swift index 314ddc0..15b9c50 100644 --- a/sources/clipbored/services/ClipboardStore.swift +++ b/sources/clipbored/services/ClipboardStore.swift @@ -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) diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 8b5ddd5..a7a6227 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 31068fc..a7d8143 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -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) diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 7826b2e..1b364f4 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -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() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 2eaf1a8..20b986b 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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, diff --git a/tests/clipboredtests/ClipboardStoreTests.swift b/tests/clipboredtests/ClipboardStoreTests.swift index 3ba5b6b..38dcc78 100644 --- a/tests/clipboredtests/ClipboardStoreTests.swift +++ b/tests/clipboredtests/ClipboardStoreTests.swift @@ -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()