WIP: add visible collect action

This commit is contained in:
Akshay Kolli
2026-06-30 03:17:40 -07:00
parent 870e284fbd
commit 582d317d28
4 changed files with 48 additions and 15 deletions

View File

@@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images - Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Sort modes for recent, most used, images, links, text, files, audio, and pinned items
- Custom named collections for organizing clips from the card context menu or by dragging cards onto collection chips - Custom named collections for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
- Copy and paste actions with Accessibility permission fallback - Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning - Image thumbnail cache with byte and file-count pruning
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type - Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type

View File

@@ -38,7 +38,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
5. Press `Esc` again and confirm the panel closes. 5. Press `Esc` again and confirm the panel closes.
6. Reopen the panel, change sort segments, and confirm each segment updates results. 6. Reopen the panel, change sort segments, and confirm each segment updates results.
7. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count. 7. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count.
8. Right-click another card and confirm Add to Collection offers Client Work as a reusable destination. 8. Select another card and confirm its Collect button offers Client Work as a reusable destination.
9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists. 9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.

View File

@@ -1134,6 +1134,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardViews.first?.debugCollectionMenuTitles ?? [] cardViews.first?.debugCollectionMenuTitles ?? []
} }
var debugFirstCardCollectActionMenuTitles: [String] {
cardViews.first?.debugCollectActionMenuTitles ?? []
}
var debugFirstCardCaptureRuleMenuTitles: [String] { var debugFirstCardCaptureRuleMenuTitles: [String] {
cardViews.first?.debugCaptureRuleMenuTitles ?? [] cardViews.first?.debugCaptureRuleMenuTitles ?? []
} }
@@ -1788,6 +1792,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return collectionMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } return collectionMenu.items.map { $0.isSeparatorItem ? "-" : $0.title }
} }
var debugCollectActionMenuTitles: [String] {
collectionAssignmentMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
}
var debugCaptureRuleMenuTitles: [String] { var debugCaptureRuleMenuTitles: [String] {
guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu else { guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu else {
return [] return []
@@ -1859,6 +1867,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private func addCollectionMenu(to menu: NSMenu) { private func addCollectionMenu(to menu: NSMenu) {
let parent = NSMenuItem(title: "Add to Collection", action: nil, keyEquivalent: "") let parent = NSMenuItem(title: "Add to Collection", action: nil, keyEquivalent: "")
let submenu = collectionAssignmentMenu()
menu.addItem(parent)
menu.setSubmenu(submenu, for: parent)
}
private func collectionAssignmentMenu() -> NSMenu {
let submenu = NSMenu(title: "Add to Collection") let submenu = NSMenu(title: "Add to Collection")
submenu.autoenablesItems = false submenu.autoenablesItems = false
@@ -1884,8 +1898,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
submenu.addItem(remove) submenu.addItem(remove)
} }
menu.addItem(parent) return submenu
menu.setSubmenu(submenu, for: parent)
} }
private func addCaptureRulesMenu(to menu: NSMenu) { private func addCaptureRulesMenu(to menu: NSMenu) {
@@ -2017,6 +2030,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)), cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)),
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu)) cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
] ]
actionRailButtons.append(cardActionButton("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:))))
actionRailButtons.append(cardActionButton("square.stack.3d.up", toolTip: itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu))) actionRailButtons.append(cardActionButton("square.stack.3d.up", toolTip: itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu)))
if canEditText { if canEditText {
actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu))) actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu)))
@@ -2062,12 +2076,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
button.wantsLayer = true button.wantsLayer = true
let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize
button.layer?.cornerRadius = size / 2 button.layer?.cornerRadius = size / 2
button.layer?.backgroundColor = isPrimary if isPrimary {
? NSColor.controlAccentColor.cgColor button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
: NSColor.white.withAlphaComponent(0.08).cgColor button.contentTintColor = .white
button.contentTintColor = isPrimary } else if toolTip == "Collect" {
? .white button.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.88).cgColor
: (toolTip == "Delete" ? NSColor.white.withAlphaComponent(0.48) : NSColor.white.withAlphaComponent(0.78)) button.contentTintColor = .white
} else {
button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor
button.contentTintColor = toolTip == "Delete"
? NSColor.white.withAlphaComponent(0.48)
: NSColor.white.withAlphaComponent(0.78)
}
button.toolTip = toolTip button.toolTip = toolTip
button.setAccessibilityLabel(toolTip) button.setAccessibilityLabel(toolTip)
button.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false
@@ -2138,6 +2158,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onTogglePin(index) onTogglePin(index)
} }
@objc private func showCollectionMenuFromAction(_ sender: NSButton) {
let menu = collectionAssignmentMenu()
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.maxY + 4), in: sender)
}
@objc private func assignToCollectionFromMenu(_ sender: NSMenuItem) { @objc private func assignToCollectionFromMenu(_ sender: NSMenuItem) {
guard let name = sender.representedObject as? String else { return } guard let name = sender.representedObject as? String else { return }
onAssignCollection(index, name) onAssignCollection(index, name)

View File

@@ -215,8 +215,8 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
drainMainQueue() drainMainQueue()
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Edit", "Delete"]) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Add to Stack", "Edit", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
@@ -225,8 +225,8 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.viewModel.selectFirstItem() fixture.viewModel.selectFirstItem()
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Preview", "Open", "Reveal", "Delete"]) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Add to Stack", "Preview", "Open", "Reveal", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 238) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 266)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
} }
@@ -397,6 +397,10 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.view.debugFirstCardCollectionMenuTitles, fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."] ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."]
) )
XCTAssertEqual(
fixture.view.debugFirstCardCollectActionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."]
)
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardCaptureRuleMenuTitles, fixture.view.debugFirstCardCaptureRuleMenuTitles,
["Ignore Ghostty", "Ignore Text Items"] ["Ignore Ghostty", "Ignore Text Items"]
@@ -433,7 +437,7 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.view.debugFirstCardMenuTitles, fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] ["Paste", "Copy", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
) )
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"]) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Remove from Stack", "Edit", "Delete"])
} }
func testStackChipAppearsFiltersAndClearsWithStack() { func testStackChipAppearsFiltersAndClearsWithStack() {
@@ -485,6 +489,10 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.view.debugFirstCardCollectionMenuTitles, fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."] ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."]
) )
XCTAssertEqual(
fixture.view.debugFirstCardCollectActionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."]
)
} }
func testBottomSafeInsetIsAppliedToPanelContent() { func testBottomSafeInsetIsAppliedToPanelContent() {