WIP: add visible clips to stack

This commit is contained in:
Akshay Kolli
2026-07-01 15:20:13 -07:00
parent ca3bdfbc70
commit 26e4b15093
5 changed files with 189 additions and 6 deletions

View File

@@ -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. 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. 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. 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 ## Copy And Paste

View File

@@ -575,6 +575,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
stackChip.onPress = { [weak self] in stackChip.onPress = { [weak self] in
self?.viewModel.selectStack() 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) configureCollectionKeyboardNavigation(for: stackChip)
if viewModel.stackCount > 0 { if viewModel.stackCount > 0 {
collectionChipOrder.append(stackChip) collectionChipOrder.append(stackChip)
@@ -782,6 +794,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
self?.viewModel.toggleSelectedStackMembership() self?.viewModel.toggleSelectedStackMembership()
} }
card.onAddVisibleToStack = { [weak self] in
self?.viewModel.addVisibleItemsToStack()
}
card.onPasteStackNext = { [weak self] in card.onPasteStackNext = { [weak self] in
self?.viewModel.pasteNextStackItem() self?.viewModel.pasteNextStackItem()
} }
@@ -1630,6 +1645,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
stackChip.isSelected stackChip.isSelected
} }
var debugStackChipMenuTitles: [String] {
stackChip.debugMenuTitles
}
func debugAddVisibleClipsToStackFromStackChip() {
stackChip.debugPerformMenuItem(titled: "Add Visible Clips to Stack")
}
func debugPressStackChip() { func debugPressStackChip() {
stackChip.onPress() stackChip.onPress()
} }
@@ -2110,6 +2133,10 @@ private final class CollectionChipView: NSView {
var onSelectFirst: () -> Void = {} var onSelectFirst: () -> Void = {}
var onSelectLast: () -> Void = {} var onSelectLast: () -> Void = {}
var onDropItem: ((UUID) -> Void)? var onDropItem: ((UUID) -> Void)?
var onAddVisibleToStack: (() -> Void)?
var onPasteStackNext: (() -> Void)?
var onCopyStackNext: (() -> Void)?
var onClearStack: (() -> Void)?
var onEdit: (() -> Void)? var onEdit: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
@@ -2308,13 +2335,45 @@ private final class CollectionChipView: NSView {
} }
override func menu(for event: NSEvent) -> NSMenu? { override func menu(for event: NSEvent) -> NSMenu? {
guard onEdit != nil || onDelete != nil else { return nil } guard hasContextMenuActions else { return nil }
return contextMenu() return contextMenu()
} }
private var hasContextMenuActions: Bool {
onAddVisibleToStack != nil
|| onPasteStackNext != nil
|| onCopyStackNext != nil
|| onClearStack != nil
|| onEdit != nil
|| onDelete != nil
}
private func contextMenu() -> NSMenu { private func contextMenu() -> NSMenu {
let menu = NSMenu(title: titleText) let menu = NSMenu(title: titleText)
menu.autoenablesItems = false 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 { if onEdit != nil {
let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "") let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "")
item.target = self item.target = self
@@ -2331,6 +2390,22 @@ private final class CollectionChipView: NSView {
return menu 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() { @objc private func editFromMenu() {
onEdit?() onEdit?()
} }
@@ -2407,6 +2482,11 @@ private final class CollectionChipView: NSView {
var debugMenuTitles: [String] { var debugMenuTitles: [String] {
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } 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 #endif
} }
@@ -2502,6 +2582,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onPastePlainText: (Int) -> Void = { _ in } var onPastePlainText: (Int) -> Void = { _ in }
var onCopyPlainText: (Int) -> Void = { _ in } var onCopyPlainText: (Int) -> Void = { _ in }
var onToggleStack: (Int) -> Void = { _ in } var onToggleStack: (Int) -> Void = { _ in }
var onAddVisibleToStack: () -> Void = {}
var onPasteStackNext: () -> Void = {} var onPasteStackNext: () -> Void = {}
var onCopyStackNext: () -> Void = {} var onCopyStackNext: () -> Void = {}
var onClearStack: () -> Void = {} var onClearStack: () -> Void = {}
@@ -2882,6 +2963,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu) addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu)
addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), 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 { if stackCount > 0 {
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu) addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu) addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu)
@@ -3291,6 +3373,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onToggleStack(index) onToggleStack(index)
} }
@objc private func addVisibleToStackFromMenu() {
onAddVisibleToStack()
}
@objc private func toggleStackFromCornerButton() { @objc private func toggleStackFromCornerButton() {
onSelect(index) onSelect(index)
onToggleStack(index) onToggleStack(index)

View File

@@ -308,6 +308,27 @@ final class ClipboardPanelViewModel {
statusMessage = "Added to Stack" 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() { func selectStack() {
guard !stackItemIDs.isEmpty else { return } guard !stackItemIDs.isEmpty else { return }
selectedCollectionName = nil selectedCollectionName = nil

View File

@@ -855,6 +855,36 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.statusMessage, "Cleared Stack") 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() { func testIgnoreSelectedSourceAppAddsPreciseCaptureRule() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -500,7 +500,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 220, accuracy: 0.5) XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 220, accuracy: 0.5)
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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( XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles, fixture.view.debugFirstCardCollectionMenuTitles,
@@ -933,7 +933,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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() fixture.view.debugShowFirstCardInClipboard()
@@ -953,7 +953,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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( XCTAssertEqual(
fixture.view.debugFirstCardCaptureRuleMenuTitles, fixture.view.debugFirstCardCaptureRuleMenuTitles,
@@ -973,7 +973,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"])
XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"]) XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"])
@@ -1046,6 +1046,51 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugStackChipCount, 0) 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() { func testCollectionMenuOffersExistingCustomCollections() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
var existing = makeTextItem("Existing client note", store: fixture.store) var existing = makeTextItem("Existing client note", store: fixture.store)