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
- 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

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.
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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()

View File

@@ -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 = [