WIP: drag cards to collections

This commit is contained in:
Akshay Kolli
2026-06-30 03:02:29 -07:00
parent a278675db0
commit 3c58ab8c38
6 changed files with 193 additions and 11 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 - 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 - 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

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

View File

@@ -389,6 +389,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.onPress = { [weak self] in chip.onPress = { [weak self] in
self?.viewModel.selectCollection(named: collectionName) self?.viewModel.selectCollection(named: collectionName)
} }
chip.onDropItem = { [weak self] itemID in
self?.viewModel.assignItem(withID: itemID, to: collectionName)
}
customCollectionButtons[collectionName] = chip customCollectionButtons[collectionName] = chip
collectionStack.addArrangedSubview(chip) collectionStack.addArrangedSubview(chip)
} }
@@ -1124,6 +1127,15 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
addSelectedClipToCollection() 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 #endif
func controlTextDidChange(_ notification: Notification) { 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 { private final class CollectionChipView: NSView {
let titleText: String let titleText: String
private let color: NSColor private let color: NSColor
@@ -1215,7 +1246,9 @@ private final class CollectionChipView: NSView {
private let countLabel = NSTextField(labelWithString: "0") private let countLabel = NSTextField(labelWithString: "0")
private(set) var isSelected = false private(set) var isSelected = false
private(set) var count = 0 private(set) var count = 0
private var isDropTargeted = false
var onPress: () -> Void = {} var onPress: () -> Void = {}
var onDropItem: ((UUID) -> Void)?
init(title: String, color: NSColor) { init(title: String, color: NSColor) {
self.titleText = title self.titleText = title
@@ -1238,6 +1271,7 @@ private final class CollectionChipView: NSView {
setAccessibilityRole(.button) setAccessibilityRole(.button)
setAccessibilityLabel(titleText) setAccessibilityLabel(titleText)
heightAnchor.constraint(equalToConstant: 26).isActive = true heightAnchor.constraint(equalToConstant: 26).isActive = true
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
dot.wantsLayer = true dot.wantsLayer = true
dot.layer?.cornerRadius = 4 dot.layer?.cornerRadius = 4
@@ -1291,7 +1325,20 @@ private final class CollectionChipView: NSView {
? NSColor.controlAccentColor.withAlphaComponent(0.16) ? NSColor.controlAccentColor.withAlphaComponent(0.16)
: NSColor.labelColor.withAlphaComponent(0.07) : NSColor.labelColor.withAlphaComponent(0.07)
).cgColor ).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?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor
layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.34).cgColor layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.34).cgColor
} else { } else {
@@ -1315,6 +1362,60 @@ private final class CollectionChipView: NSView {
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
onPress() 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 { private final class AspectFillImageView: NSView {
@@ -1405,6 +1506,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onDelete: (Int) -> Void = { _ in } var onDelete: (Int) -> Void = { _ in }
private let index: Int private let index: Int
private let itemID: UUID
private let itemKind: ClipboardItemKind private let itemKind: ClipboardItemKind
private let itemIsPinned: Bool private let itemIsPinned: Bool
private let itemIsStacked: Bool private let itemIsStacked: Bool
@@ -1434,6 +1536,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
stackCount: Int = 0 stackCount: Int = 0
) { ) {
self.index = index self.index = index
self.itemID = item.id
self.itemKind = item.kind self.itemKind = item.kind
self.itemIsPinned = item.isPinned self.itemIsPinned = item.isPinned
self.itemIsStacked = isStacked self.itemIsStacked = isStacked
@@ -1516,11 +1619,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
mouseDownLocation = nil mouseDownLocation = nil
let writers = onPasteboardWriters(index) let writers = onPasteboardWriters(index)
guard !writers.isEmpty else { return } let dragWriters = writers.isEmpty ? [internalDragPasteboardItem()] : writers
onSelect(index) onSelect(index)
ClipboardCardDragContext.itemID = itemID
let preview = dragPreviewImage() 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 draggingItem = NSDraggingItem(pasteboardWriter: writer)
let offsetAmount = CGFloat(offset) * 4 let offsetAmount = CGFloat(offset) * 4
let frame = NSRect( 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 { func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
.copy .copy
} }
@@ -1548,6 +1658,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
true true
} }
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
if ClipboardCardDragContext.itemID == itemID {
ClipboardCardDragContext.itemID = nil
}
}
override func menu(for event: NSEvent) -> NSMenu? { override func menu(for event: NSEvent) -> NSMenu? {
onSelect(index) onSelect(index)
return contextMenu() return contextMenu()
@@ -1598,6 +1714,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var debugQuickPasteBadgeText: String? { var debugQuickPasteBadgeText: String? {
quickPasteBadgeLabel?.stringValue quickPasteBadgeLabel?.stringValue
} }
var debugItemID: UUID {
itemID
}
#endif #endif
private func contextMenu() -> NSMenu { private func contextMenu() -> NSMenu {

View File

@@ -417,13 +417,13 @@ final class ClipboardPanelViewModel {
func assignSelected(to collectionName: String?) { func assignSelected(to collectionName: String?) {
guard let item = selectedItem else { return } guard let item = selectedItem else { return }
selectedItemID = item.id selectedItemID = item.id
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) assign(item: item, to: collectionName)
store.setCollection(item.id, name: normalizedName) }
if let normalizedName {
statusMessage = "Added to \(normalizedName)" func assignItem(withID id: UUID, to collectionName: String?) {
} else { guard let item = items.first(where: { $0.id == id }) else { return }
statusMessage = "Removed from collection" selectedItemID = selectedItem?.id
} assign(item: item, to: collectionName)
} }
func ignoreSelectedSourceApp() { func ignoreSelectedSourceApp() {
@@ -819,6 +819,16 @@ final class ClipboardPanelViewModel {
return min(lhs, rhs) 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)? { private func sourceIgnoreRule(for item: ClipboardItem) -> (value: String, displayName: String)? {
if let bundleID = item.sourceAppBundleId?.clipboardTrimmed, !bundleID.isEmpty { if let bundleID = item.sourceAppBundleId?.clipboardTrimmed, !bundleID.isEmpty {
let sourceApp = item.sourceApp?.clipboardTrimmed let sourceApp = item.sourceApp?.clipboardTrimmed

View File

@@ -215,6 +215,30 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.visibleItems.isEmpty) 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() { func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -263,6 +263,33 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"]) 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() { func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
let names = [ let names = [