diff --git a/README.md b/README.md index b7a9878..890f403 100644 --- a/README.md +++ b/README.md @@ -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 - 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 -- 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 - 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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 3f0c952..fbd2561 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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. 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. -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. 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. diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 56dc599..4a9635c 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -1134,6 +1134,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugCollectionMenuTitles ?? [] } + var debugFirstCardCollectActionMenuTitles: [String] { + cardViews.first?.debugCollectActionMenuTitles ?? [] + } + var debugFirstCardCaptureRuleMenuTitles: [String] { cardViews.first?.debugCaptureRuleMenuTitles ?? [] } @@ -1788,6 +1792,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return collectionMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } } + var debugCollectActionMenuTitles: [String] { + collectionAssignmentMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } + } + var debugCaptureRuleMenuTitles: [String] { guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu else { return [] @@ -1859,6 +1867,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private func addCollectionMenu(to menu: NSMenu) { 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") submenu.autoenablesItems = false @@ -1884,8 +1898,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { submenu.addItem(remove) } - menu.addItem(parent) - menu.setSubmenu(submenu, for: parent) + return submenu } 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(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))) if canEditText { actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu))) @@ -2062,12 +2076,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { button.wantsLayer = true let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize button.layer?.cornerRadius = size / 2 - button.layer?.backgroundColor = isPrimary - ? NSColor.controlAccentColor.cgColor - : NSColor.white.withAlphaComponent(0.08).cgColor - button.contentTintColor = isPrimary - ? .white - : (toolTip == "Delete" ? NSColor.white.withAlphaComponent(0.48) : NSColor.white.withAlphaComponent(0.78)) + if isPrimary { + button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + button.contentTintColor = .white + } else if toolTip == "Collect" { + button.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.88).cgColor + 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.setAccessibilityLabel(toolTip) button.translatesAutoresizingMaskIntoConstraints = false @@ -2138,6 +2158,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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) { guard let name = sender.representedObject as? String else { return } onAssignCollection(index, name) diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index a3f1d97..0c41188 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -215,8 +215,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Edit", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Add to Stack", "Edit", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) @@ -225,8 +225,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.selectFirstItem() XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Add to Stack", "Preview", "Open", "Reveal", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 238) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Add to Stack", "Preview", "Open", "Reveal", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 266) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) } @@ -397,6 +397,10 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.view.debugFirstCardCollectionMenuTitles, ["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( fixture.view.debugFirstCardCaptureRuleMenuTitles, ["Ignore Ghostty", "Ignore Text Items"] @@ -433,7 +437,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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"] ) - 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() { @@ -485,6 +489,10 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.view.debugFirstCardCollectionMenuTitles, ["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() {