WIP: manage collections from rail

This commit is contained in:
Akshay Kolli
2026-06-30 04:01:57 -07:00
parent 16e2200244
commit 3e38514387
8 changed files with 336 additions and 10 deletions

View File

@@ -18,7 +18,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`, result jump-back to full history, and optional local OCR for copied images - Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, result jump-back to full history, 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, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips - Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
- 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

@@ -41,10 +41,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. 8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. 10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 11. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
12. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
13. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected. 13. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
14. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 14. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
15. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
## Copy And Paste ## Copy And Paste

View File

@@ -252,6 +252,77 @@ final class SettingsModel {
}?.value }?.value
} }
@discardableResult
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) -> String? {
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
let normalizedNewName = ClipboardCollectionDefaults.normalizedName(newName) else {
return nil
}
let oldIndex = customCollectionNames.firstIndex {
$0.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame
}
let oldCanonicalName = oldIndex.map { customCollectionNames[$0] } ?? normalizedCurrentName
let targetIndex = customCollectionNames.firstIndex {
$0.caseInsensitiveCompare(normalizedNewName) == .orderedSame
}
let targetCanonicalName: String
if let targetIndex, targetIndex != oldIndex {
targetCanonicalName = customCollectionNames[targetIndex]
} else {
targetCanonicalName = normalizedNewName
}
var changed = false
if let oldIndex {
if let targetIndex, targetIndex != oldIndex {
customCollectionNames.remove(at: oldIndex)
changed = true
} else if customCollectionNames[oldIndex] != normalizedNewName {
customCollectionNames[oldIndex] = normalizedNewName
changed = true
}
} else if targetIndex == nil {
customCollectionNames.append(normalizedNewName)
changed = true
}
let existingColor = collectionColorHex(forCollectionNamed: oldCanonicalName)
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(oldCanonicalName) == .orderedSame {
collectionColorHexes.removeValue(forKey: key)
changed = true
}
let resolvedColor = Self.normalizedHexColor(colorHex) ?? existingColor
if let resolvedColor,
collectionColorHexes[targetCanonicalName] != resolvedColor {
collectionColorHexes[targetCanonicalName] = resolvedColor
changed = true
}
if changed {
storeAndNotify(.collections)
}
return targetCanonicalName
}
@discardableResult
func deleteCollection(named name: String) -> String? {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
var changed = false
if let index = customCollectionNames.firstIndex(where: { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }) {
customCollectionNames.remove(at: index)
changed = true
}
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(normalizedName) == .orderedSame {
collectionColorHexes.removeValue(forKey: key)
changed = true
}
if changed {
storeAndNotify(.collections)
}
return normalizedName
}
private static func readShortcut(from value: String?) -> ShortcutBinding? { private static func readShortcut(from value: String?) -> ShortcutBinding? {
guard let value else { return nil } guard let value else { return nil }
return ShortcutBinding(encoded: value) return ShortcutBinding(encoded: value)

View File

@@ -557,6 +557,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.onDropItem = { [weak self] itemID in chip.onDropItem = { [weak self] itemID in
self?.viewModel.assignItem(withID: itemID, to: collectionName) self?.viewModel.assignItem(withID: itemID, to: collectionName)
} }
chip.onEdit = { [weak self] in
self?.editCollection(named: collectionName)
}
chip.onDelete = { [weak self] in
self?.deleteCollection(named: collectionName)
}
customCollectionButtons[collectionName] = chip customCollectionButtons[collectionName] = chip
collectionStack.addArrangedSubview(chip) collectionStack.addArrangedSubview(chip)
} }
@@ -1347,6 +1353,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
createCollectionFromToolbar() createCollectionFromToolbar()
} }
func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] {
customCollectionButtons[collectionName]?.debugMenuTitles ?? []
}
func debugEditCollection(named collectionName: String, to newName: String, colorHex: String) {
viewModel.updateCollection(named: collectionName, to: newName, colorHex: colorHex)
}
func debugDeleteCollection(named collectionName: String) {
viewModel.deleteCollection(named: collectionName)
}
func debugShowFirstCardInClipboard() { func debugShowFirstCardInClipboard() {
showSelectedInClipboard(at: 0) showSelectedInClipboard(at: 0)
} }
@@ -1432,6 +1450,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true) viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
} }
private func editCollection(named collectionName: String) {
guard let request = requestCollectionEdit(named: collectionName) else { return }
viewModel.updateCollection(named: collectionName, to: request.name, colorHex: request.colorHex)
}
private func deleteCollection(named collectionName: String) {
let count = viewModel.collectionCount(named: collectionName)
guard confirmDeleteCollection(named: collectionName, count: count) else { return }
viewModel.deleteCollection(named: collectionName)
}
private func requestCollectionCreation() -> CollectionCreationRequest? { private func requestCollectionCreation() -> CollectionCreationRequest? {
#if DEBUG #if DEBUG
if let collectionNameProviderForTesting { if let collectionNameProviderForTesting {
@@ -1445,12 +1474,38 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
#endif #endif
return requestCollectionDetails(
title: "New Collection",
message: "Name this collection and choose its color.",
actionTitle: "Create",
initialName: "",
initialColor: ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
)
}
private func requestCollectionEdit(named collectionName: String) -> CollectionCreationRequest? {
requestCollectionDetails(
title: "Edit Collection",
message: "Update this collection's name and color.",
actionTitle: "Save",
initialName: collectionName,
initialColor: collectionColor(forCollectionNamed: collectionName)
)
}
private func requestCollectionDetails(
title: String,
message: String,
actionTitle: String,
initialName: String,
initialColor: NSColor
) -> CollectionCreationRequest? {
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = "Collection name" input.placeholderString = "Collection name"
input.stringValue = "" input.stringValue = initialName
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28)) let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
colorWell.color = ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection") colorWell.color = initialColor
let colorLabel = NSTextField(labelWithString: "Color") let colorLabel = NSTextField(labelWithString: "Color")
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
@@ -1468,10 +1523,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64) stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "New Collection" alert.messageText = title
alert.informativeText = "Name this collection and choose its color." alert.informativeText = message
alert.accessoryView = stack alert.accessoryView = stack
alert.addButton(withTitle: "Create") alert.addButton(withTitle: actionTitle)
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = input alert.window.initialFirstResponder = input
@@ -1484,6 +1539,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color) colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
) )
} }
private func confirmDeleteCollection(named collectionName: String, count: Int) -> Bool {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Delete \(collectionName)?"
let noun = count == 1 ? "clip" : "clips"
alert.informativeText = count > 0
? "This removes \(count) \(noun) in this collection from clipboard history."
: "This removes the empty collection."
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
} }
private enum ClipboardItemDragPasteboard { private enum ClipboardItemDragPasteboard {
@@ -1516,6 +1584,8 @@ private final class CollectionChipView: NSView {
private var isDropTargeted = false private var isDropTargeted = false
var onPress: () -> Void = {} var onPress: () -> Void = {}
var onDropItem: ((UUID) -> Void)? var onDropItem: ((UUID) -> Void)?
var onEdit: (() -> Void)?
var onDelete: (() -> Void)?
init(title: String, color: NSColor) { init(title: String, color: NSColor) {
self.titleText = title self.titleText = title
@@ -1630,6 +1700,38 @@ private final class CollectionChipView: NSView {
onPress() onPress()
} }
override func menu(for event: NSEvent) -> NSMenu? {
guard onEdit != nil || onDelete != nil else { return nil }
return contextMenu()
}
private func contextMenu() -> NSMenu {
let menu = NSMenu(title: titleText)
menu.autoenablesItems = false
if onEdit != nil {
let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
if onDelete != nil {
if !menu.items.isEmpty {
menu.addItem(NSMenuItem.separator())
}
let item = NSMenuItem(title: "Delete Collection", action: #selector(deleteFromMenu), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
return menu
}
@objc private func editFromMenu() {
onEdit?()
}
@objc private func deleteFromMenu() {
onDelete?()
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] } guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
setDropTargeted(true) setDropTargeted(true)
@@ -1682,6 +1784,10 @@ private final class CollectionChipView: NSView {
func debugDropItem(_ itemID: UUID) { func debugDropItem(_ itemID: UUID) {
onDropItem?(itemID) onDropItem?(itemID)
} }
var debugMenuTitles: [String] {
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
}
#endif #endif
} }

View File

@@ -503,6 +503,39 @@ final class ClipboardPanelViewModel {
settings.collectionColorHex(forCollectionNamed: name) settings.collectionColorHex(forCollectionNamed: name)
} }
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) {
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
let normalizedNewName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) else {
return
}
for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
store.setCollection(item.id, name: normalizedNewName)
}
if selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
selectedCollectionName = normalizedNewName
} else {
recomputeVisibleItems()
}
statusMessage = "Updated \(normalizedNewName)"
}
func deleteCollection(named name: String) {
guard let normalizedName = settings.deleteCollection(named: name) else { return }
let matchingIDs = items
.filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame }
.map(\.id)
for id in matchingIDs {
store.remove(id)
}
if selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame {
selectedCollectionName = nil
} else {
recomputeVisibleItems()
}
statusMessage = "Deleted \(normalizedName)"
}
func clearSearch() { func clearSearch() {
searchText = "" searchText = ""
} }

View File

@@ -273,6 +273,57 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8") XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8")
} }
func testUpdateCollectionRenamesAssignedItemsAndColor() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
var research = makeTextItem("research note", createdAt: Date(timeIntervalSince1970: 100))
research.collectionName = "Research Stack"
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(research)
store.upsert(outside)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
viewModel.updateCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.collectionNames, ["Product Research"])
XCTAssertEqual(viewModel.collectionColorHex(named: "Product Research"), "#3366FF")
XCTAssertEqual(viewModel.selectedCollectionName, "Product Research")
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["research note"])
XCTAssertEqual(viewModel.statusMessage, "Updated Product Research")
XCTAssertEqual(store.items.first(where: { $0.payload == "research note" })?.collectionName, "Product Research")
}
func testDeleteCollectionRemovesCollectionItemsFromHistory() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
client.collectionName = "Client Work"
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(client)
store.upsert(outside)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8")
viewModel.deleteCollection(named: "Client Work")
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.collectionNames, [])
XCTAssertNil(viewModel.selectedCollectionName)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note"])
XCTAssertEqual(store.items.map(\.payload), ["outside note"])
XCTAssertEqual(viewModel.statusMessage, "Deleted Client Work")
}
func testSearchTextRecomputesVisibleItemsImmediately() { func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -221,6 +221,42 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
} }
func testCollectionChipsExposeManagementMenuActions() {
let fixture = makePanelFixture()
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCustomCollectionMenuTitles(named: "Research Stack"), ["Edit Collection...", "-", "Delete Collection"])
}
func testCollectionChipManagementRenamesAndDeletesCollections() {
let fixture = makePanelFixture()
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
var item = makeTextItem("Collect this note", store: fixture.store)
item.collectionName = "Research Stack"
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
fixture.view.debugEditCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Product Research"])
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Product Research")
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Product Research")
XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF")
fixture.view.debugDeleteCollection(named: "Product Research")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, [])
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), [])
XCTAssertEqual(fixture.store.items.map(\.payload), [])
}
func testSelectedCardActionsRespectSelectedKind() { func testSelectedCardActionsRespectSelectedKind() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))

View File

@@ -49,4 +49,32 @@ final class SettingsModelTests: XCTestCase {
XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"]) XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"])
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355") XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355")
} }
func testCustomCollectionsCanBeUpdatedAndDeleted() {
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let settings = SettingsModel(defaults: defaults)
settings.ensureCollection(named: "Research Stack", colorHex: "#0A9EB8")
settings.ensureCollection(named: "Client Work", colorHex: "#FF3355")
let updatedName = settings.updateCollection(named: "research stack", to: "Product Research", colorHex: "#3366FF")
XCTAssertEqual(updatedName, "Product Research")
XCTAssertEqual(settings.customCollectionNames, ["Product Research", "Client Work"])
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Research Stack"))
XCTAssertEqual(settings.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
settings.deleteCollection(named: "client work")
XCTAssertEqual(settings.customCollectionNames, ["Product Research"])
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Client Work"))
let restored = SettingsModel(defaults: defaults)
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
}
} }