WIP: add empty color-coded collections

This commit is contained in:
Akshay Kolli
2026-06-30 03:51:06 -07:00
parent 0b8e0d6be1
commit 16e2200244
11 changed files with 321 additions and 60 deletions

View File

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

View File

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

View File

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

View File

@@ -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)"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}

View File

@@ -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")
}
}