diff --git a/README.md b/README.md index 39451d4..5e5b590 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 +- Custom named collections for organizing clips from the card 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 3c17161..7f9ae3d 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -42,6 +42,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 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. +12. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 89acb73..120a62d 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -389,6 +389,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onPress = { [weak self] in self?.viewModel.selectCollection(named: collectionName) } + chip.onDropItem = { [weak self] itemID in + self?.viewModel.assignItem(withID: itemID, to: collectionName) + } customCollectionButtons[collectionName] = chip collectionStack.addArrangedSubview(chip) } @@ -1124,6 +1127,15 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addSelectedClipToCollection() } + func debugDropFirstCard(onCollectionNamed collectionName: String) { + guard let itemID = cardViews.first?.debugItemID else { return } + customCollectionButtons[collectionName]?.debugDropItem(itemID) + } + + var debugCustomCollectionDropTargets: [String] { + viewModel.collectionNames.filter { customCollectionButtons[$0]?.debugAcceptsItemDrops == true } + } + #endif func controlTextDidChange(_ notification: Notification) { @@ -1207,6 +1219,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } } +private enum ClipboardItemDragPasteboard { + static let itemIDType = NSPasteboard.PasteboardType("com.clipbored.clipboard-item-id") + static let acceptedTypes: [NSPasteboard.PasteboardType] = [ + itemIDType, + .string, + .URL, + .fileURL, + .tiff, + .png, + .pdf, + .sound, + .rtf + ] +} + +private enum ClipboardCardDragContext { + static var itemID: UUID? +} + private final class CollectionChipView: NSView { let titleText: String private let color: NSColor @@ -1215,7 +1246,9 @@ private final class CollectionChipView: NSView { private let countLabel = NSTextField(labelWithString: "0") private(set) var isSelected = false private(set) var count = 0 + private var isDropTargeted = false var onPress: () -> Void = {} + var onDropItem: ((UUID) -> Void)? init(title: String, color: NSColor) { self.titleText = title @@ -1238,6 +1271,7 @@ private final class CollectionChipView: NSView { setAccessibilityRole(.button) setAccessibilityLabel(titleText) heightAnchor.constraint(equalToConstant: 26).isActive = true + registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes) dot.wantsLayer = true dot.layer?.cornerRadius = 4 @@ -1291,7 +1325,20 @@ private final class CollectionChipView: NSView { ? NSColor.controlAccentColor.withAlphaComponent(0.16) : NSColor.labelColor.withAlphaComponent(0.07) ).cgColor - if selected { + updateChrome() + } + + private func setDropTargeted(_ targeted: Bool) { + guard isDropTargeted != targeted else { return } + isDropTargeted = targeted + updateChrome() + } + + private func updateChrome() { + if isDropTargeted { + layer?.backgroundColor = color.withAlphaComponent(0.18).cgColor + layer?.borderColor = color.withAlphaComponent(0.68).cgColor + } else if isSelected { layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.34).cgColor } else { @@ -1315,6 +1362,60 @@ private final class CollectionChipView: NSView { override func mouseDown(with event: NSEvent) { onPress() } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] } + setDropTargeted(true) + return .copy + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + guard onDropItem != nil, draggedItemID(from: sender) != nil else { + setDropTargeted(false) + return [] + } + setDropTargeted(true) + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + setDropTargeted(false) + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + onDropItem != nil && draggedItemID(from: sender) != nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let itemID = draggedItemID(from: sender), let onDropItem else { return false } + onDropItem(itemID) + setDropTargeted(false) + return true + } + + override func concludeDragOperation(_ sender: NSDraggingInfo?) { + setDropTargeted(false) + } + + private func draggedItemID(from sender: NSDraggingInfo) -> UUID? { + if let itemID = ClipboardCardDragContext.itemID { + return itemID + } + + return sender.draggingPasteboard + .string(forType: ClipboardItemDragPasteboard.itemIDType) + .flatMap(UUID.init(uuidString:)) + } + + #if DEBUG + var debugAcceptsItemDrops: Bool { + onDropItem != nil + } + + func debugDropItem(_ itemID: UUID) { + onDropItem?(itemID) + } + #endif } private final class AspectFillImageView: NSView { @@ -1405,6 +1506,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onDelete: (Int) -> Void = { _ in } private let index: Int + private let itemID: UUID private let itemKind: ClipboardItemKind private let itemIsPinned: Bool private let itemIsStacked: Bool @@ -1434,6 +1536,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { stackCount: Int = 0 ) { self.index = index + self.itemID = item.id self.itemKind = item.kind self.itemIsPinned = item.isPinned self.itemIsStacked = isStacked @@ -1516,11 +1619,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { mouseDownLocation = nil let writers = onPasteboardWriters(index) - guard !writers.isEmpty else { return } + let dragWriters = writers.isEmpty ? [internalDragPasteboardItem()] : writers onSelect(index) + ClipboardCardDragContext.itemID = itemID let preview = dragPreviewImage() - let dragItems = writers.enumerated().map { offset, writer in + let dragItems = dragWriters.enumerated().map { offset, writer in let draggingItem = NSDraggingItem(pasteboardWriter: writer) let offsetAmount = CGFloat(offset) * 4 let frame = NSRect( @@ -1540,6 +1644,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } } + private func internalDragPasteboardItem() -> NSPasteboardItem { + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setString(itemID.uuidString, forType: ClipboardItemDragPasteboard.itemIDType) + return pasteboardItem + } + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { .copy } @@ -1548,6 +1658,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { true } + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + if ClipboardCardDragContext.itemID == itemID { + ClipboardCardDragContext.itemID = nil + } + } + override func menu(for event: NSEvent) -> NSMenu? { onSelect(index) return contextMenu() @@ -1598,6 +1714,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var debugQuickPasteBadgeText: String? { quickPasteBadgeLabel?.stringValue } + + var debugItemID: UUID { + itemID + } #endif private func contextMenu() -> NSMenu { diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index ef82b52..f4d5e76 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -417,13 +417,13 @@ final class ClipboardPanelViewModel { func assignSelected(to collectionName: String?) { guard let item = selectedItem else { return } selectedItemID = item.id - let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) - store.setCollection(item.id, name: normalizedName) - if let normalizedName { - statusMessage = "Added to \(normalizedName)" - } else { - statusMessage = "Removed from collection" - } + assign(item: item, to: collectionName) + } + + func assignItem(withID id: UUID, to collectionName: String?) { + guard let item = items.first(where: { $0.id == id }) else { return } + selectedItemID = selectedItem?.id + assign(item: item, to: collectionName) } func ignoreSelectedSourceApp() { @@ -819,6 +819,16 @@ final class ClipboardPanelViewModel { return min(lhs, rhs) } + private func assign(item: ClipboardItem, to collectionName: String?) { + let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) + store.setCollection(item.id, name: normalizedName) + if let normalizedName { + statusMessage = "Added to \(normalizedName)" + } else { + statusMessage = "Removed from collection" + } + } + private func sourceIgnoreRule(for item: ClipboardItem) -> (value: String, displayName: String)? { if let bundleID = item.sourceAppBundleId?.clipboardTrimmed, !bundleID.isEmpty { let sourceApp = item.sourceApp?.clipboardTrimmed diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index cf764a0..63bce1a 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -215,6 +215,30 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertTrue(viewModel.visibleItems.isEmpty) } + func testAssignItemByIDAddsCollectionWithoutChangingSelection() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let first = makeTextItem("first clip", createdAt: Date(timeIntervalSince1970: 100)) + let second = makeTextItem("second 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: 0) + + viewModel.assignItem(withID: first.id, to: "Pinned Research") + store.flushPersistenceForTesting() + waitForVisibleItems(in: viewModel, count: 2) + + XCTAssertEqual(viewModel.selectedItem?.id, second.id) + XCTAssertEqual(viewModel.collectionNames, ["Pinned Research"]) + XCTAssertEqual(viewModel.collectionCount(named: "Pinned Research"), 1) + XCTAssertEqual(viewModel.statusMessage, "Added to Pinned Research") + } + func testSearchTextRecomputesVisibleItemsImmediately() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 7e76f33..5bf1ab7 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -263,6 +263,33 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"]) } + func testCardsCanDropOntoCollectionChipsToOrganize() { + let fixture = makePanelFixture() + var existing = makeTextItem("Existing client note", store: fixture.store) + existing.collectionName = "Client Work" + fixture.store.upsert(existing) + let dropped = makeTextItem("Drop this note", store: fixture.store) + fixture.store.upsert(dropped) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Client Work"]) + XCTAssertEqual(fixture.view.debugCustomCollectionDropTargets, ["Client Work"]) + XCTAssertEqual(fixture.viewModel.visibleItems.first?.id, dropped.id) + + fixture.view.debugDropFirstCard(onCollectionNamed: "Client Work") + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Client Work") + XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [2]) + + fixture.viewModel.selectCollection(named: "Client Work") + drainMainQueue() + + XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.payload)), ["Existing client note", "Drop this note"]) + } + func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() { let fixture = makePanelFixture() let names = [