WIP: drag cards to collections
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user