diff --git a/README.md b/README.md index b27a25c..1d2d535 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Command + ,` opens settings - `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text - `Command + G` shows a filtered result back in the full clipboard history + - `Shift + Command + N` creates a new collection - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references - 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 for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips, with consistent collection colors in chips and collection card headers +- 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 - 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 1eeee12..105f96b 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -38,9 +38,9 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 5. Press `Esc` once with a non-empty search field and confirm search clears. 6. Press `Esc` again and confirm the panel closes. 7. Reopen the panel, change sort segments, and confirm each segment updates results. -8. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count. -9. Select another card and confirm its Collect button offers Client Work as a reusable destination. -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 assignment persists after quitting and reopening ClipBored. +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. diff --git a/sources/clipbored/app/AppDelegate.swift b/sources/clipbored/app/AppDelegate.swift index ffc09a3..b8ee791 100644 --- a/sources/clipbored/app/AppDelegate.swift +++ b/sources/clipbored/app/AppDelegate.swift @@ -437,7 +437,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } case .pollProfile: monitor.setPaused(settings.pauseCapture) - case .status, .other: + case .status, .collections, .other: break case .captureStatus: break diff --git a/sources/clipbored/models/SettingsModel.swift b/sources/clipbored/models/SettingsModel.swift index fadb4b8..15ae0be 100644 --- a/sources/clipbored/models/SettingsModel.swift +++ b/sources/clipbored/models/SettingsModel.swift @@ -1,7 +1,7 @@ import Foundation final class SettingsModel { - enum Change { + enum Change: Equatable { case maxHistoryItems case imageCacheMaxBytes case openShortcut @@ -12,6 +12,7 @@ final class SettingsModel { case pauseCapture case pollProfile case captureStatus + case collections case status case other } @@ -35,6 +36,8 @@ final class SettingsModel { static let pauseCapture = "pauseCapture" static let clearHistoryOnQuit = "clearHistoryOnQuit" static let accessibilityNoticeShown = "accessibilityNoticeShown" + static let customCollectionNames = "customCollectionNames" + static let collectionColorHexes = "collectionColorHexes" } var maxHistoryItems: Int { @@ -88,6 +91,8 @@ final class SettingsModel { var clearHistoryOnQuit: Bool { didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } } } + private(set) var customCollectionNames: [String] + private(set) var collectionColorHexes: [String: String] private(set) var launchAtLoginErrorMessage: String = "" private(set) var accessibilityPermissionStatusMessage: String = "" private(set) var captureStatusMessage: String = "" @@ -123,6 +128,8 @@ final class SettingsModel { excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false + customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? []) + collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes)) accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) @@ -151,6 +158,8 @@ final class SettingsModel { defaults.set(excludeSensitive, forKey: Keys.excludeSensitive) defaults.set(pauseCapture, forKey: Keys.pauseCapture) defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit) + defaults.set(customCollectionNames, forKey: Keys.customCollectionNames) + defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes) } func observe(_ observer: @escaping (Change) -> Void) { @@ -204,6 +213,45 @@ final class SettingsModel { notify(.status) } + @discardableResult + func ensureCollection(named name: String, colorHex: String? = nil) -> String? { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil } + let existingName = customCollectionNames.first { + $0.caseInsensitiveCompare(normalizedName) == .orderedSame + } + let canonicalName = existingName ?? normalizedName + var changed = false + + if existingName == nil { + customCollectionNames.append(normalizedName) + changed = true + } + + if let normalizedHex = Self.normalizedHexColor(colorHex), + collectionColorHexes[canonicalName] != normalizedHex { + for key in collectionColorHexes.keys where key.caseInsensitiveCompare(canonicalName) == .orderedSame && key != canonicalName { + collectionColorHexes.removeValue(forKey: key) + } + collectionColorHexes[canonicalName] = normalizedHex + changed = true + } + + if changed { + storeAndNotify(.collections) + } + return normalizedName + } + + func collectionColorHex(forCollectionNamed name: String) -> String? { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil } + if let exact = collectionColorHexes[normalizedName] { + return exact + } + return collectionColorHexes.first { storedName, _ in + storedName.caseInsensitiveCompare(normalizedName) == .orderedSame + }?.value + } + private static func readShortcut(from value: String?) -> ShortcutBinding? { guard let value else { return nil } return ShortcutBinding(encoded: value) @@ -218,4 +266,39 @@ final class SettingsModel { maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes) } + + private static func normalizedCollectionNames(_ names: [String]) -> [String] { + var normalized: [String] = [] + for name in names { + guard let value = ClipboardCollectionDefaults.normalizedName(name) else { continue } + guard !normalized.contains(where: { $0.caseInsensitiveCompare(value) == .orderedSame }) else { continue } + normalized.append(value) + } + return normalized + } + + private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] { + guard let rawValue else { return [:] } + var normalized: [String: String] = [:] + for (name, color) in rawValue { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name), + let hex = normalizedHexColor(color as? String) else { + continue + } + normalized[normalizedName] = hex + } + return normalized + } + + private static func normalizedHexColor(_ value: String?) -> String? { + guard let value else { return nil } + var hex = value.clipboardTrimmed.uppercased() + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6, hex.allSatisfy({ $0.isHexDigit }) else { + return nil + } + return "#\(hex)" + } } diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 15a743b..0f08b8e 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -16,6 +16,7 @@ struct ClipboardPanelReflowPlan { enum ClipboardPanelShortcutAction: Equatable { case copy case copyPlainText + case newCollection case open case pastePlainText case pasteStackNext @@ -398,6 +399,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel viewModel.copySelected() case .copyPlainText: viewModel.copySelectedPlainText() + case .newCollection: + panelView.createCollection() case .open: viewModel.openSelected() case .pastePlainText: @@ -493,6 +496,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel return .toggleStack case 8: return .copyPlainText + case 45: + return .newCollection case 9: return .pastePlainText case 36: diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index f36eea4..a3838e0 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -65,7 +65,14 @@ private enum ClipboardCollectionVisuals { } } - static func color(forCollectionNamed name: String) -> NSColor { + static func color(forCollectionNamed name: String, overrideHex: String? = nil) -> NSColor { + if let color = color(fromHex: overrideHex) { + return color + } + return defaultColor(forCollectionNamed: name) + } + + static func defaultColor(forCollectionNamed name: String) -> NSColor { switch name { case "Useful Links": return customPalette[0] @@ -80,6 +87,34 @@ private enum ClipboardCollectionVisuals { } } + static func defaultColorHex(forCollectionNamed name: String) -> String { + hexString(for: defaultColor(forCollectionNamed: name)) + } + + static func hexString(for color: NSColor) -> String { + let rgb = color.usingColorSpace(.deviceRGB) ?? color + let red = Int((rgb.redComponent * 255).rounded()) + let green = Int((rgb.greenComponent * 255).rounded()) + let blue = Int((rgb.blueComponent * 255).rounded()) + return String(format: "#%02X%02X%02X", red, green, blue) + } + + private static func color(fromHex value: String?) -> NSColor? { + guard let value else { return nil } + var hex = value.clipboardTrimmed + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6, + let rawValue = Int(hex, radix: 16) else { + return nil + } + let red = CGFloat((rawValue >> 16) & 0xFF) / 255 + let green = CGFloat((rawValue >> 8) & 0xFF) / 255 + let blue = CGFloat(rawValue & 0xFF) / 255 + return NSColor(deviceRed: red, green: green, blue: blue, alpha: 1) + } + private static func stablePaletteIndex(for name: String) -> Int { var hash: UInt64 = 1_469_598_103_934_665_603 for scalar in name.lowercased().unicodeScalars { @@ -90,6 +125,11 @@ private enum ClipboardCollectionVisuals { } } +private struct CollectionCreationRequest { + let name: String + let colorHex: String +} + final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private enum Metrics { static let actionButtonSize: CGFloat = 30 @@ -549,10 +589,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addCollectionButton.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor addCollectionButton.contentTintColor = .secondaryLabelColor - addCollectionButton.toolTip = "Add selected clip to a new collection" - addCollectionButton.setAccessibilityLabel("Add selected clip to a new collection") + addCollectionButton.toolTip = "New collection" + addCollectionButton.setAccessibilityLabel("New collection") addCollectionButton.target = self - addCollectionButton.action = #selector(addSelectedClipToCollection) + addCollectionButton.action = #selector(createCollectionFromToolbar) addCollectionButton.translatesAutoresizingMaskIntoConstraints = false addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true addCollectionButton.heightAnchor.constraint(equalToConstant: 26).isActive = true @@ -576,7 +616,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func collectionColor(forCollectionNamed name: String) -> NSColor { - ClipboardCollectionVisuals.color(forCollectionNamed: name) + ClipboardCollectionVisuals.color(forCollectionNamed: name, overrideHex: viewModel.collectionColorHex(named: name)) } private func applyCardDensity() { @@ -670,7 +710,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { isStacked: viewModel.isItemStacked(at: index), stackCount: viewModel.stackCount, canShowInClipboard: viewModel.canShowVisibleItemsInClipboard, - selectedCollectionName: viewModel.selectedCollectionName + selectedCollectionName: viewModel.selectedCollectionName, + selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) } ) card.onSelect = { [weak self] selected in self?.viewModel.selectItem(at: selected) @@ -812,9 +853,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func updateAddCollectionButtonState() { - let hasSelectedItem = viewModel.selectedItem != nil - addCollectionButton.isEnabled = hasSelectedItem - addCollectionButton.alphaValue = hasSelectedItem ? 1.0 : 0.42 + addCollectionButton.isEnabled = true + addCollectionButton.alphaValue = 1.0 } private func scrollCardIntoView(_ card: NSView) { @@ -856,7 +896,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") { return .ready } - if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") { + if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") { return .action } if lower.hasPrefix("error") || lower.contains("failed") { @@ -968,6 +1008,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { ) } + if let collectionName = viewModel.selectedCollectionName { + return ( + "No clips in \(collectionName)", + "Drag clips here or use Collect to add them." + ) + } + if viewModel.totalItemCount == 0 { return ( "Copy something to start your history", @@ -1229,6 +1276,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if stackChip.isSelected { return stackChip.titleText } + if let custom = customCollectionButtons.first(where: { $0.value.isSelected }) { + return custom.value.titleText + } return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText } @@ -1248,7 +1298,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { var debugCustomCollectionColorHexes: [String: String] { Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in - (name, ClipboardItemCardView.debugHex(ClipboardCollectionVisuals.color(forCollectionNamed: name))) + (name, ClipboardCollectionVisuals.hexString(for: collectionColor(forCollectionNamed: name))) }) } @@ -1294,7 +1344,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } func debugPressAddCollectionButton() { - addSelectedClipToCollection() + createCollectionFromToolbar() } func debugShowFirstCardInClipboard() { @@ -1373,18 +1423,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.stringValue = viewModel.searchText } - @objc private func addSelectedClipToCollection() { - guard viewModel.selectedItem != nil, - let name = requestCollectionName() else { - return - } - viewModel.assignSelected(to: name) + func createCollection() { + createCollectionFromToolbar() } - private func requestCollectionName() -> String? { + @objc private func createCollectionFromToolbar() { + guard let request = requestCollectionCreation() else { return } + viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true) + } + + private func requestCollectionCreation() -> CollectionCreationRequest? { #if DEBUG if let collectionNameProviderForTesting { - return ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting()) + guard let name = ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting()) else { + return nil + } + return CollectionCreationRequest( + name: name, + colorHex: ClipboardCollectionVisuals.defaultColorHex(forCollectionNamed: name) + ) } #endif @@ -1392,18 +1449,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { input.placeholderString = "Collection name" input.stringValue = "" + let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28)) + colorWell.color = ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection") + + let colorLabel = NSTextField(labelWithString: "Color") + colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + colorLabel.textColor = .secondaryLabelColor + + let colorRow = NSStackView(views: [colorLabel, colorWell]) + colorRow.orientation = .horizontal + colorRow.alignment = .centerY + colorRow.spacing = 10 + + let stack = NSStackView(views: [input, colorRow]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 10 + stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64) + let alert = NSAlert() alert.messageText = "New Collection" - alert.informativeText = "Name this collection and add the selected clip to it." - alert.accessoryView = input - alert.addButton(withTitle: "Add") + alert.informativeText = "Name this collection and choose its color." + alert.accessoryView = stack + alert.addButton(withTitle: "Create") alert.addButton(withTitle: "Cancel") alert.window.initialFirstResponder = input - guard alert.runModal() == .alertFirstButtonReturn else { + guard alert.runModal() == .alertFirstButtonReturn, + let name = ClipboardCollectionDefaults.normalizedName(input.stringValue) else { return nil } - return ClipboardCollectionDefaults.normalizedName(input.stringValue) + return CollectionCreationRequest( + name: name, + colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color) + ) } } @@ -1720,7 +1799,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { isStacked: Bool = false, stackCount: Int = 0, canShowInClipboard: Bool = false, - selectedCollectionName: String? = nil + selectedCollectionName: String? = nil, + selectedCollectionColor: NSColor? = nil ) { let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName) let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName) @@ -1737,7 +1817,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId) self.itemCollectionName = normalizedItemCollection self.activeCollectionName = activeCollection - self.activeCollectionColor = activeCollection.map(ClipboardCollectionVisuals.color(forCollectionNamed:)) + self.activeCollectionColor = activeCollection.map { name in + selectedCollectionColor ?? ClipboardCollectionVisuals.color(forCollectionNamed: name) + } self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) } super.init(frame: .zero) configure(item: item, thumbnail: thumbnail) @@ -1925,11 +2007,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } static func debugHex(_ color: NSColor) -> String { - let rgb = color.usingColorSpace(.deviceRGB) ?? color - let red = Int((rgb.redComponent * 255).rounded()) - let green = Int((rgb.greenComponent * 255).rounded()) - let blue = Int((rgb.blueComponent * 255).rounded()) - return String(format: "#%02X%02X%02X", red, green, blue) + ClipboardCollectionVisuals.hexString(for: color) } #endif diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index a2d203a..8df32cc 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -111,11 +111,18 @@ final class ClipboardPanelViewModel { } } settings.observe { [weak self] change in - guard case .captureStatus = change else { return } self?.notifyMain { - self?.statusMessage = "" - self?.onStatusMessageChanged?("") - self?.onCaptureStatusChanged?() + switch change { + case .captureStatus: + self?.statusMessage = "" + self?.onStatusMessageChanged?("") + self?.onCaptureStatusChanged?() + case .collections: + self?.recomputeVisibleItems() + self?.onCollectionsChanged?() + default: + break + } } } } @@ -155,11 +162,20 @@ final class ClipboardPanelViewModel { ClipboardCollectionDefaults.normalizedName(item.collectionName) } ) - let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) } - let customNames = assignedNames + let configuredNames = settings.customCollectionNames + let configuredNameSet = Set(configuredNames.map { $0.lowercased() }) + let allNames = assignedNames.union(configuredNames) + let defaultNames = ClipboardCollectionDefaults.names.filter { allNames.contains($0) } + var configuredCustomNames: [String] = [] + for name in configuredNames where !ClipboardCollectionDefaults.names.contains(name) { + guard !configuredCustomNames.contains(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) else { continue } + configuredCustomNames.append(name) + } + let assignedCustomNames = assignedNames .filter { !ClipboardCollectionDefaults.names.contains($0) } + .filter { !configuredNameSet.contains($0.lowercased()) } .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - return defaultNames + customNames + return defaultNames + configuredCustomNames + assignedCustomNames } func collectionCount(for sortMode: ClipboardSortMode) -> Int { @@ -472,6 +488,21 @@ final class ClipboardPanelViewModel { selectedCollectionName = normalizedName } + func createCollection(named name: String, colorHex: String? = nil, selectAfterCreate: Bool = true) { + guard let normalizedName = settings.ensureCollection(named: name, colorHex: colorHex) else { return } + statusMessage = "Created \(normalizedName)" + if selectAfterCreate { + selectCollection(named: normalizedName) + } else { + recomputeVisibleItems() + onCollectionsChanged?() + } + } + + func collectionColorHex(named name: String) -> String? { + settings.collectionColorHex(forCollectionNamed: name) + } + func clearSearch() { searchText = "" } @@ -857,6 +888,9 @@ final class ClipboardPanelViewModel { private func assign(item: ClipboardItem, to collectionName: String?) { let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) + if let normalizedName { + settings.ensureCollection(named: normalizedName) + } store.setCollection(item.id, name: normalizedName) if let normalizedName { statusMessage = "Added to \(normalizedName)" diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index fb78fcd..8cbdfb4 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -182,6 +182,7 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) + XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext) } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 49613dd..5c965a1 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -239,6 +239,40 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "Added to Pinned Research") } + func testCreateCollectionAddsEmptySelectableCollection() { + let suiteName = "com.clipbored.testmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + settings.maxHistoryItems = 10 + settings.includeImageTextInSearch = false + settings.pruneDuplicates = false + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + + viewModel.createCollection(named: " Client Work ", colorHex: "#0A9EB8") + + XCTAssertEqual(viewModel.collectionNames, ["Client Work"]) + XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 0) + XCTAssertEqual(viewModel.selectedCollectionName, "Client Work") + XCTAssertTrue(viewModel.visibleItems.isEmpty) + XCTAssertEqual(viewModel.collectionColorHex(named: "client work"), "#0A9EB8") + XCTAssertEqual(viewModel.statusMessage, "Created Client Work") + + let restoredSettings = SettingsModel(defaults: defaults) + let restoredViewModel = ClipboardPanelViewModel(store: store, settings: restoredSettings, cacheService: cacheService) + waitForVisibleItems(in: restoredViewModel, count: 1) + XCTAssertEqual(restoredViewModel.collectionNames, ["Client Work"]) + XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8") + } + func testSearchTextRecomputesVisibleItemsImmediately() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index a3d2ed6..dbc7a6f 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -184,16 +184,10 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") } - func testCollectionRailAddButtonCreatesCollectionForSelectedClip() { + func testCollectionRailAddButtonCreatesEmptyCollection() { let fixture = makePanelFixture() XCTAssertTrue(fixture.view.debugCollectionRailContainsAddButton) - XCTAssertFalse(fixture.view.debugAddCollectionButtonIsEnabled) - - fixture.store.upsert(makeTextItem("Collect this note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugAddCollectionButtonIsEnabled) fixture.view.debugSetCollectionNameProvider { " Research Stack " } @@ -201,22 +195,29 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Research Stack") + XCTAssertEqual(fixture.viewModel.statusMessage, "Created Research Stack") XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Research Stack"]) - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text") - XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "Research Stack - 17 characters") + XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [0]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Research Stack") + XCTAssertEqual(fixture.view.debugVisibleCardCount, 0) + XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No clips in Research Stack") + XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Drag clips here or use Collect to add them.") + } - fixture.viewModel.selectCollection(named: "Research Stack") + func testCollectionFilteredCardsUseStoredCollectionHeaderColor() { + 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() XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"]) XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack") XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now") - XCTAssertEqual( - fixture.view.debugFirstCardHeaderColorHex, - fixture.view.debugCustomCollectionColorHexes["Research Stack"] ?? "" - ) + XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#0A9EB8") + XCTAssertEqual(fixture.view.debugCustomCollectionColorHexes["Research Stack"], "#0A9EB8") XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") } diff --git a/tests/clipboredtests/SettingsModelTests.swift b/tests/clipboredtests/SettingsModelTests.swift index d050f7b..f7dd3d8 100644 --- a/tests/clipboredtests/SettingsModelTests.swift +++ b/tests/clipboredtests/SettingsModelTests.swift @@ -25,4 +25,28 @@ final class SettingsModelTests: XCTestCase { let restored = SettingsModel(defaults: defaults) XCTAssertTrue(restored.showDockIcon) } + + func testCustomCollectionsPersistWithNormalizedColors() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.ensureCollection(named: " Research Stack ", colorHex: "0a9eb8") + settings.ensureCollection(named: "research stack", colorHex: "#FF3355") + settings.ensureCollection(named: "Client Work", colorHex: "not-a-color") + + XCTAssertEqual(settings.customCollectionNames, ["Research Stack", "Client Work"]) + XCTAssertEqual(settings.collectionColorHex(forCollectionNamed: "research stack"), "#FF3355") + XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Client Work")) + XCTAssertEqual(changes, [.collections, .collections, .collections]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"]) + XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355") + } }