From 3e38514387813b2a867bc7c73b1c2748988ce82c Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 04:01:57 -0700 Subject: [PATCH] WIP: manage collections from rail --- README.md | 2 +- docs/SMOKE_TEST.md | 9 +- sources/clipbored/models/SettingsModel.swift | 71 +++++++++++ .../clipbored/views/ClipboardPanelView.swift | 116 +++++++++++++++++- .../views/ClipboardPanelViewModel.swift | 33 +++++ .../ClipboardPanelViewModelTests.swift | 51 ++++++++ .../ClipboardPanelViewTests.swift | 36 ++++++ tests/clipboredtests/SettingsModelTests.swift | 28 +++++ 8 files changed, 336 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1d2d535..d99ee87 100644 --- a/README.md +++ b/README.md @@ -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 - 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 -- 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 - 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 105f96b..f39cfb1 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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. 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. -11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. -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. -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. -14. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. +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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +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. 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 diff --git a/sources/clipbored/models/SettingsModel.swift b/sources/clipbored/models/SettingsModel.swift index 15ae0be..06ba347 100644 --- a/sources/clipbored/models/SettingsModel.swift +++ b/sources/clipbored/models/SettingsModel.swift @@ -252,6 +252,77 @@ final class SettingsModel { }?.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? { guard let value else { return nil } return ShortcutBinding(encoded: value) diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index a3838e0..9743983 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -557,6 +557,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onDropItem = { [weak self] itemID in 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 collectionStack.addArrangedSubview(chip) } @@ -1347,6 +1353,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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() { showSelectedInClipboard(at: 0) } @@ -1432,6 +1450,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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? { #if DEBUG if let collectionNameProviderForTesting { @@ -1445,12 +1474,38 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } #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)) input.placeholderString = "Collection name" - input.stringValue = "" + input.stringValue = initialName 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") 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) let alert = NSAlert() - alert.messageText = "New Collection" - alert.informativeText = "Name this collection and choose its color." + alert.messageText = title + alert.informativeText = message alert.accessoryView = stack - alert.addButton(withTitle: "Create") + alert.addButton(withTitle: actionTitle) alert.addButton(withTitle: "Cancel") alert.window.initialFirstResponder = input @@ -1484,6 +1539,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 { @@ -1516,6 +1584,8 @@ private final class CollectionChipView: NSView { private var isDropTargeted = false var onPress: () -> Void = {} var onDropItem: ((UUID) -> Void)? + var onEdit: (() -> Void)? + var onDelete: (() -> Void)? init(title: String, color: NSColor) { self.titleText = title @@ -1630,6 +1700,38 @@ private final class CollectionChipView: NSView { 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 { guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] } setDropTargeted(true) @@ -1682,6 +1784,10 @@ private final class CollectionChipView: NSView { func debugDropItem(_ itemID: UUID) { onDropItem?(itemID) } + + var debugMenuTitles: [String] { + contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } + } #endif } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 8df32cc..199a071 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -503,6 +503,39 @@ final class ClipboardPanelViewModel { 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() { searchText = "" } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 5c965a1..2604bc5 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -273,6 +273,57 @@ final class ClipboardPanelViewModelTests: XCTestCase { 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() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index dbc7a6f..ba0beeb 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -221,6 +221,42 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) diff --git a/tests/clipboredtests/SettingsModelTests.swift b/tests/clipboredtests/SettingsModelTests.swift index f7dd3d8..c71b6b5 100644 --- a/tests/clipboredtests/SettingsModelTests.swift +++ b/tests/clipboredtests/SettingsModelTests.swift @@ -49,4 +49,32 @@ final class SettingsModelTests: XCTestCase { XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"]) 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") + } }