diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 415d01a..7892c5e 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -18,8 +18,10 @@ enum ClipboardPanelShortcutAction: Equatable { case copyPlainText case open case pastePlainText + case pasteStackNext case preview case reveal + case toggleStack } final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate { @@ -378,10 +380,14 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel viewModel.openSelected() case .pastePlainText: viewModel.pasteSelectedPlainText() + case .pasteStackNext: + viewModel.pasteNextStackItem() case .preview: previewSelected() case .reveal: viewModel.revealSelected() + case .toggleStack: + viewModel.toggleSelectedStackMembership() } } @@ -445,10 +451,14 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) guard relevantModifiers == [.command, .shift] else { return nil } switch keyCode { + case 1: + return .toggleStack case 8: return .copyPlainText case 9: return .pastePlainText + case 36: + return .pasteStackNext default: return nil } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 77e203d..8f8822d 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -323,6 +323,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.onCollectionsChanged = { [weak self] in self?.handleCollectionsChanged() } + viewModel.onStackChanged = { [weak self] in + self?.reloadItems() + self?.updateSelection() + self?.updateStatus(self?.viewModel.statusMessage ?? "") + } viewModel.onCaptureStatusChanged = { [weak self] in self?.updateStatus(self?.viewModel.statusMessage ?? "") } @@ -515,7 +520,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { item: item, thumbnail: viewModel.thumbnail(for: item), index: index, - collectionNames: collectionNames + collectionNames: collectionNames, + isStacked: viewModel.isItemStacked(at: index), + stackCount: viewModel.stackCount ) card.onSelect = { [weak self] selected in self?.viewModel.selectItem(at: selected) @@ -536,6 +543,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.copySelectedPlainText() } + card.onToggleStack = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.toggleSelectedStackMembership() + } + card.onPasteStackNext = { [weak self] in + self?.viewModel.pasteNextStackItem() + } + card.onCopyStackNext = { [weak self] in + self?.viewModel.copyNextStackItem() + } + card.onClearStack = { [weak self] in + self?.viewModel.clearStack() + } card.onEditText = { [weak self] selected in self?.editText(at: selected) } @@ -677,7 +697,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") { + if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") { return .action } if lower.hasPrefix("error") || lower.contains("failed") { @@ -1318,6 +1338,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onCopy: (Int) -> Void = { _ in } var onPastePlainText: (Int) -> Void = { _ in } var onCopyPlainText: (Int) -> Void = { _ in } + var onToggleStack: (Int) -> Void = { _ in } + var onPasteStackNext: () -> Void = {} + var onCopyStackNext: () -> Void = {} + var onClearStack: () -> Void = {} var onEditText: (Int) -> Void = { _ in } var onPreview: (Int) -> Void = { _ in } var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } @@ -1330,6 +1354,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private let index: Int private let itemKind: ClipboardItemKind private let itemIsPinned: Bool + private let itemIsStacked: Bool + private let stackCount: Int private let itemCollectionName: String? private let collectionNames: [String] private let contentView = NSView() @@ -1343,10 +1369,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var mouseDownLocation: NSPoint? private var trackingAreaRef: NSTrackingArea? - init(item: ClipboardItem, thumbnail: NSImage?, index: Int, collectionNames: [String] = []) { + init( + item: ClipboardItem, + thumbnail: NSImage?, + index: Int, + collectionNames: [String] = [], + isStacked: Bool = false, + stackCount: Int = 0 + ) { self.index = index self.itemKind = item.kind self.itemIsPinned = item.isPinned + self.itemIsStacked = isStacked + self.stackCount = stackCount self.itemCollectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) } super.init(frame: .zero) @@ -1505,6 +1540,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu) addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), 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) + addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu) + addMenuItem("Clear Stack", action: #selector(clearStackFromMenu), to: menu) + } if canEditText { addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu) } @@ -1641,6 +1682,7 @@ 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)) ] + actionRailButtons.append(cardActionButton("square.stack.3d.up", toolTip: itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu))) if canEditText { actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu))) } @@ -1725,6 +1767,22 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onCopyPlainText(index) } + @objc private func toggleStackFromMenu() { + onToggleStack(index) + } + + @objc private func pasteStackNextFromMenu() { + onPasteStackNext() + } + + @objc private func copyStackNextFromMenu() { + onCopyStackNext() + } + + @objc private func clearStackFromMenu() { + onClearStack() + } + @objc private func editTextFromMenu() { onEditText(index) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index ed8e5aa..25019a3 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -44,6 +44,12 @@ final class ClipboardPanelViewModel { private let cacheService: ClipboardCacheService private let pasteService: PasteActionService private var selectedItemID: UUID? + private var stackItemIDs: [UUID] = [] { + didSet { + guard oldValue != stackItemIDs else { return } + notifyMain { self.onStackChanged?() } + } + } var targetApplicationProvider: () -> NSRunningApplication? = { nil } var willPasteToTarget: () -> Void = {} var onVisibleItemsChanged: (([ClipboardItem]) -> Void)? @@ -51,6 +57,7 @@ final class ClipboardPanelViewModel { var onStatusMessageChanged: ((String) -> Void)? var onSortModeChanged: ((ClipboardSortMode) -> Void)? var onCollectionsChanged: (() -> Void)? + var onStackChanged: (() -> Void)? var onCaptureStatusChanged: (() -> Void)? init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) { @@ -85,6 +92,10 @@ final class ClipboardPanelViewModel { items.count } + var stackCount: Int { + stackItemIDs.count + } + var collectionNames: [String] { let assignedNames = Set( items.compactMap { item -> String? in @@ -188,6 +199,55 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: result.message) } + func isItemStacked(at index: Int) -> Bool { + guard index >= 0 && index < visibleItems.count else { return false } + return stackItemIDs.contains(visibleItems[index].id) + } + + func toggleSelectedStackMembership() { + guard let item = selectedItem else { return } + if let existingIndex = stackItemIDs.firstIndex(of: item.id) { + stackItemIDs.remove(at: existingIndex) + statusMessage = "Removed from Stack" + return + } + + stackItemIDs.append(item.id) + statusMessage = "Added to Stack" + } + + func clearStack() { + guard !stackItemIDs.isEmpty else { + statusMessage = "Stack is empty" + return + } + stackItemIDs.removeAll() + statusMessage = "Cleared Stack" + } + + func copyNextStackItem() { + guard let item = nextStackItem() else { + statusMessage = "Stack is empty" + return + } + + let result = pasteService.copy(item) + handleStackActionResult(result, item: item) + } + + func pasteNextStackItem() { + guard let item = nextStackItem() else { + statusMessage = "Stack is empty" + return + } + + let result = pasteService.paste(item, targetApp: targetApplicationProvider()) + if case .pasted = result { + willPasteToTarget() + } + handleStackActionResult(result, item: item) + } + func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { guard index >= 0 && index < visibleItems.count else { return [] } return pasteService.pasteboardWriters(for: visibleItems[index]) @@ -311,6 +371,7 @@ final class ClipboardPanelViewModel { } func recomputeVisibleItems() { + pruneStackItems() let previousSelection = selectedItemID let query = searchText.clipboardTrimmed.lowercased() visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName) @@ -331,6 +392,48 @@ final class ClipboardPanelViewModel { onCollectionsChanged?() } + private func nextStackItem() -> ClipboardItem? { + pruneStackItems() + guard let id = stackItemIDs.first else { return nil } + return items.first { $0.id == id } + } + + private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) { + if case .failed(let message) = result { + statusMessage = message + return + } + + consumeStackItem(item.id) + store.markUsed(item.id) + selectedItemID = item.id + switch result { + case .copiedNeedsPermission: + statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically." + case .pasted: + statusMessage = "Pasted from Stack" + case .copied: + statusMessage = "Copied from Stack" + default: + statusMessage = result.message + } + settings.setPasteStatus(message: statusMessage) + } + + private func consumeStackItem(_ id: UUID) { + guard let index = stackItemIDs.firstIndex(of: id) else { return } + stackItemIDs.remove(at: index) + } + + private func pruneStackItems() { + guard !stackItemIDs.isEmpty else { return } + let existingIDs = Set(items.map(\.id)) + let pruned = stackItemIDs.filter { existingIDs.contains($0) } + if pruned != stackItemIDs { + stackItemIDs = pruned + } + } + internal func computeVisibleItems( from items: [ClipboardItem], query: String, diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index e3df0ac..0563d32 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -159,8 +159,10 @@ final class ClipboardPanelControllerTests: XCTestCase { } func testModifiedShortcutsMapToPlainTextActions() { + XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) + XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext) } func testModifiedShortcutsRequireCommandShiftOnly() { diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 694db1d..44f5d73 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -304,6 +304,69 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(store.items.first?.useCount, 1) } + func testStackPastesQueuedItemsInOrderAndConsumesThem() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let first = makeTextItem("first stacked clip", createdAt: Date(timeIntervalSince1970: 100)) + let second = makeTextItem("second stacked clip", createdAt: Date(timeIntervalSince1970: 200)) + store.upsert(first) + store.upsert(second) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 2) + + viewModel.selectItem(at: 1) + viewModel.toggleSelectedStackMembership() + viewModel.selectItem(at: 0) + viewModel.toggleSelectedStackMembership() + + XCTAssertEqual(viewModel.stackCount, 2) + XCTAssertEqual(viewModel.statusMessage, "Added to Stack") + + viewModel.pasteNextStackItem() + store.flushPersistenceForTesting() + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "first stacked clip") + XCTAssertEqual(viewModel.statusMessage, "Copied from Stack") + XCTAssertEqual(viewModel.stackCount, 1) + XCTAssertEqual(store.items.first(where: { $0.id == first.id })?.useCount, 1) + + viewModel.copyNextStackItem() + store.flushPersistenceForTesting() + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "second stacked clip") + XCTAssertEqual(viewModel.statusMessage, "Copied from Stack") + XCTAssertEqual(viewModel.stackCount, 0) + } + + func testStackToggleAndClearUpdateCount() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("stack toggle clip", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + + viewModel.toggleSelectedStackMembership() + XCTAssertEqual(viewModel.stackCount, 1) + XCTAssertEqual(viewModel.statusMessage, "Added to Stack") + XCTAssertTrue(viewModel.isItemStacked(at: 0)) + + viewModel.toggleSelectedStackMembership() + XCTAssertEqual(viewModel.stackCount, 0) + XCTAssertEqual(viewModel.statusMessage, "Removed from Stack") + XCTAssertFalse(viewModel.isItemStacked(at: 0)) + + viewModel.toggleSelectedStackMembership() + viewModel.clearStack() + XCTAssertEqual(viewModel.stackCount, 0) + XCTAssertEqual(viewModel.statusMessage, "Cleared Stack") + } + func testUpdateSelectedTextRefreshesVisibleItemAndSearch() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 3c0f567..57a274c 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -184,8 +184,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Edit", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 154) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Edit", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) @@ -194,8 +194,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.selectFirstItem() XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Preview", "Open", "Reveal", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Preview", "Open", "Reveal", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 238) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) } @@ -333,7 +333,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Add to Stack", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual( fixture.view.debugFirstCardCollectionMenuTitles, @@ -349,10 +349,27 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] ) } + func testStackedCardsExposeStackManagementActions() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Stackable text", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.viewModel.toggleSelectedStackMembership() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual( + fixture.view.debugFirstCardMenuTitles, + ["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"]) + } + func testCollectionMenuOffersExistingCustomCollections() { let fixture = makePanelFixture() var existing = makeTextItem("Existing client note", store: fixture.store)