From 26e4b150932080f1fb24d2d70db1a9cda84044fc Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Wed, 1 Jul 2026 15:20:13 -0700 Subject: [PATCH] WIP: add visible clips to stack --- docs/SMOKE_TEST.md | 1 + .../clipbored/views/ClipboardPanelView.swift | 88 ++++++++++++++++++- .../views/ClipboardPanelViewModel.swift | 21 +++++ .../ClipboardPanelViewModelTests.swift | 30 +++++++ .../ClipboardPanelViewTests.swift | 55 ++++++++++-- 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 5a82faa..f3d8463 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -68,6 +68,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view. 36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text. 37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text. +38. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index e919095..56dfd69 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -575,6 +575,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.onPress = { [weak self] in self?.viewModel.selectStack() } + stackChip.onAddVisibleToStack = { [weak self] in + self?.viewModel.addVisibleItemsToStack() + } + stackChip.onPasteStackNext = { [weak self] in + self?.viewModel.pasteNextStackItem() + } + stackChip.onCopyStackNext = { [weak self] in + self?.viewModel.copyNextStackItem() + } + stackChip.onClearStack = { [weak self] in + self?.viewModel.clearStack() + } configureCollectionKeyboardNavigation(for: stackChip) if viewModel.stackCount > 0 { collectionChipOrder.append(stackChip) @@ -782,6 +794,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.toggleSelectedStackMembership() } + card.onAddVisibleToStack = { [weak self] in + self?.viewModel.addVisibleItemsToStack() + } card.onPasteStackNext = { [weak self] in self?.viewModel.pasteNextStackItem() } @@ -1630,6 +1645,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.isSelected } + var debugStackChipMenuTitles: [String] { + stackChip.debugMenuTitles + } + + func debugAddVisibleClipsToStackFromStackChip() { + stackChip.debugPerformMenuItem(titled: "Add Visible Clips to Stack") + } + func debugPressStackChip() { stackChip.onPress() } @@ -2110,6 +2133,10 @@ private final class CollectionChipView: NSView { var onSelectFirst: () -> Void = {} var onSelectLast: () -> Void = {} var onDropItem: ((UUID) -> Void)? + var onAddVisibleToStack: (() -> Void)? + var onPasteStackNext: (() -> Void)? + var onCopyStackNext: (() -> Void)? + var onClearStack: (() -> Void)? var onEdit: (() -> Void)? var onDelete: (() -> Void)? @@ -2308,13 +2335,45 @@ private final class CollectionChipView: NSView { } override func menu(for event: NSEvent) -> NSMenu? { - guard onEdit != nil || onDelete != nil else { return nil } + guard hasContextMenuActions else { return nil } return contextMenu() } + private var hasContextMenuActions: Bool { + onAddVisibleToStack != nil + || onPasteStackNext != nil + || onCopyStackNext != nil + || onClearStack != nil + || onEdit != nil + || onDelete != nil + } + private func contextMenu() -> NSMenu { let menu = NSMenu(title: titleText) menu.autoenablesItems = false + if onAddVisibleToStack != nil { + let item = NSMenuItem(title: "Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), keyEquivalent: "") + item.target = self + menu.addItem(item) + } + if onPasteStackNext != nil { + let item = NSMenuItem(title: "Paste Stack Next", action: #selector(pasteStackNextFromMenu), keyEquivalent: "") + item.target = self + menu.addItem(item) + } + if onCopyStackNext != nil { + let item = NSMenuItem(title: "Copy Stack Next", action: #selector(copyStackNextFromMenu), keyEquivalent: "") + item.target = self + menu.addItem(item) + } + if onClearStack != nil { + let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "") + item.target = self + menu.addItem(item) + } + if (onEdit != nil || onDelete != nil) && !menu.items.isEmpty { + menu.addItem(NSMenuItem.separator()) + } if onEdit != nil { let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "") item.target = self @@ -2331,6 +2390,22 @@ private final class CollectionChipView: NSView { return menu } + @objc private func addVisibleToStackFromMenu() { + onAddVisibleToStack?() + } + + @objc private func pasteStackNextFromMenu() { + onPasteStackNext?() + } + + @objc private func copyStackNextFromMenu() { + onCopyStackNext?() + } + + @objc private func clearStackFromMenu() { + onClearStack?() + } + @objc private func editFromMenu() { onEdit?() } @@ -2407,6 +2482,11 @@ private final class CollectionChipView: NSView { var debugMenuTitles: [String] { contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } } + + func debugPerformMenuItem(titled title: String) { + guard let item = contextMenu().items.first(where: { $0.title == title }) else { return } + _ = item.target?.perform(item.action, with: item) + } #endif } @@ -2502,6 +2582,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onPastePlainText: (Int) -> Void = { _ in } var onCopyPlainText: (Int) -> Void = { _ in } var onToggleStack: (Int) -> Void = { _ in } + var onAddVisibleToStack: () -> Void = {} var onPasteStackNext: () -> Void = {} var onCopyStackNext: () -> Void = {} var onClearStack: () -> Void = {} @@ -2882,6 +2963,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu) addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu) + addMenuItem("Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), to: menu) if stackCount > 0 { addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu) addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu) @@ -3291,6 +3373,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onToggleStack(index) } + @objc private func addVisibleToStackFromMenu() { + onAddVisibleToStack() + } + @objc private func toggleStackFromCornerButton() { onSelect(index) onToggleStack(index) diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 959f82b..0be8c81 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -308,6 +308,27 @@ final class ClipboardPanelViewModel { statusMessage = "Added to Stack" } + func addVisibleItemsToStack() { + pruneStackItems() + guard !visibleItems.isEmpty else { + statusMessage = "No visible clips to stack" + return + } + + let existingIDs = Set(stackItemIDs) + let newIDs = visibleItems + .map(\.id) + .filter { !existingIDs.contains($0) } + guard !newIDs.isEmpty else { + statusMessage = "Visible clips are already in Stack" + return + } + + stackItemIDs.append(contentsOf: newIDs) + let noun = newIDs.count == 1 ? "clip" : "clips" + statusMessage = "Added \(newIDs.count) \(noun) to Stack" + } + func selectStack() { guard !stackItemIDs.isEmpty else { return } selectedCollectionName = nil diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 7698b90..60b6356 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -855,6 +855,36 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "Cleared Stack") } + func testAddVisibleItemsToStackQueuesFilteredShelfInOrder() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let olderNeedle = makeTextItem("older visible needle", createdAt: Date(timeIntervalSince1970: 100)) + let hidden = makeTextItem("hidden meeting note", createdAt: Date(timeIntervalSince1970: 200)) + let newerNeedle = makeTextItem("newer visible needle", createdAt: Date(timeIntervalSince1970: 300)) + store.upsert(olderNeedle) + store.upsert(hidden) + store.upsert(newerNeedle) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 3) + viewModel.searchText = "needle" + + XCTAssertEqual(viewModel.visibleItems.map(\.id), [newerNeedle.id, olderNeedle.id]) + + viewModel.addVisibleItemsToStack() + XCTAssertEqual(viewModel.stackCount, 2) + XCTAssertEqual(viewModel.statusMessage, "Added 2 clips to Stack") + + viewModel.selectStack() + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["newer visible needle", "older visible needle"]) + + viewModel.addVisibleItemsToStack() + XCTAssertEqual(viewModel.stackCount, 2) + XCTAssertEqual(viewModel.statusMessage, "Visible clips are already in Stack") + } + func testIgnoreSelectedSourceAppAddsPreciseCaptureRule() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index d50d863..0554456 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -500,7 +500,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 220, accuracy: 0.5) XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) } @@ -899,7 +899,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Rename...", "Add to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual( fixture.view.debugFirstCardCollectionMenuTitles, @@ -933,7 +933,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) fixture.view.debugShowFirstCardInClipboard() @@ -953,7 +953,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual( fixture.view.debugFirstCardCaptureRuleMenuTitles, @@ -973,7 +973,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Rename...", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Rename...", "Remove from Stack", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"]) XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"]) @@ -1046,6 +1046,51 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugStackChipCount, 0) } + func testStackChipMenuAddsVisibleShelfToQueue() { + let fixture = makePanelFixture() + var older = makeTextItem("Older batch stack item", store: fixture.store) + older.createdAt = Date(timeIntervalSince1970: 100) + older.lastUsedAt = older.createdAt + var middle = makeTextItem("Middle batch stack item", store: fixture.store) + middle.createdAt = Date(timeIntervalSince1970: 200) + middle.lastUsedAt = middle.createdAt + var newest = makeTextItem("Newest batch stack item", store: fixture.store) + newest.createdAt = Date(timeIntervalSince1970: 300) + newest.lastUsedAt = newest.createdAt + fixture.store.upsert(older) + fixture.store.upsert(middle) + fixture.store.upsert(newest) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.viewModel.selectItem(at: 0) + fixture.viewModel.toggleSelectedStackMembership() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugStackChipIsVisible) + XCTAssertEqual(fixture.view.debugStackChipCount, 1) + XCTAssertEqual( + fixture.view.debugStackChipMenuTitles, + ["Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack"] + ) + + fixture.view.debugAddVisibleClipsToStackFromStackChip() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugStackChipCount, 3) + XCTAssertEqual(fixture.view.debugStatusText, "Added 2 clips to Stack") + + fixture.view.debugPressStackChip() + drainMainQueue() + + XCTAssertEqual( + fixture.viewModel.visibleItems.map(\.payload), + ["Newest batch stack item", "Middle batch stack item", "Older batch stack item"] + ) + } + func testCollectionMenuOffersExistingCustomCollections() { let fixture = makePanelFixture() var existing = makeTextItem("Existing client note", store: fixture.store)