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 + ,` opens settings
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text - `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 - `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 - 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 - 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 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 - 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

@@ -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. 5. Press `Esc` once with a non-empty search field and confirm search clears.
6. Press `Esc` again and confirm the panel closes. 6. Press `Esc` again and confirm the panel closes.
7. Reopen the panel, change sort segments, and confirm each segment updates results. 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. 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. Select another card and confirm its Collect button offers Client Work as a reusable destination. 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 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. 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. 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. 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: case .pollProfile:
monitor.setPaused(settings.pauseCapture) monitor.setPaused(settings.pauseCapture)
case .status, .other: case .status, .collections, .other:
break break
case .captureStatus: case .captureStatus:
break break

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
final class SettingsModel { final class SettingsModel {
enum Change { enum Change: Equatable {
case maxHistoryItems case maxHistoryItems
case imageCacheMaxBytes case imageCacheMaxBytes
case openShortcut case openShortcut
@@ -12,6 +12,7 @@ final class SettingsModel {
case pauseCapture case pauseCapture
case pollProfile case pollProfile
case captureStatus case captureStatus
case collections
case status case status
case other case other
} }
@@ -35,6 +36,8 @@ final class SettingsModel {
static let pauseCapture = "pauseCapture" static let pauseCapture = "pauseCapture"
static let clearHistoryOnQuit = "clearHistoryOnQuit" static let clearHistoryOnQuit = "clearHistoryOnQuit"
static let accessibilityNoticeShown = "accessibilityNoticeShown" static let accessibilityNoticeShown = "accessibilityNoticeShown"
static let customCollectionNames = "customCollectionNames"
static let collectionColorHexes = "collectionColorHexes"
} }
var maxHistoryItems: Int { var maxHistoryItems: Int {
@@ -88,6 +91,8 @@ final class SettingsModel {
var clearHistoryOnQuit: Bool { var clearHistoryOnQuit: Bool {
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } } 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 launchAtLoginErrorMessage: String = ""
private(set) var accessibilityPermissionStatusMessage: String = "" private(set) var accessibilityPermissionStatusMessage: String = ""
private(set) var captureStatusMessage: String = "" private(set) var captureStatusMessage: String = ""
@@ -123,6 +128,8 @@ final class SettingsModel {
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) 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 accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
@@ -151,6 +158,8 @@ final class SettingsModel {
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive) defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
defaults.set(pauseCapture, forKey: Keys.pauseCapture) defaults.set(pauseCapture, forKey: Keys.pauseCapture)
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit) defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
} }
func observe(_ observer: @escaping (Change) -> Void) { func observe(_ observer: @escaping (Change) -> Void) {
@@ -204,6 +213,45 @@ final class SettingsModel {
notify(.status) 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? { 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)
@@ -218,4 +266,39 @@ final class SettingsModel {
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes) 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 { enum ClipboardPanelShortcutAction: Equatable {
case copy case copy
case copyPlainText case copyPlainText
case newCollection
case open case open
case pastePlainText case pastePlainText
case pasteStackNext case pasteStackNext
@@ -398,6 +399,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
viewModel.copySelected() viewModel.copySelected()
case .copyPlainText: case .copyPlainText:
viewModel.copySelectedPlainText() viewModel.copySelectedPlainText()
case .newCollection:
panelView.createCollection()
case .open: case .open:
viewModel.openSelected() viewModel.openSelected()
case .pastePlainText: case .pastePlainText:
@@ -493,6 +496,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return .toggleStack return .toggleStack
case 8: case 8:
return .copyPlainText return .copyPlainText
case 45:
return .newCollection
case 9: case 9:
return .pastePlainText return .pastePlainText
case 36: 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 { switch name {
case "Useful Links": case "Useful Links":
return customPalette[0] 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 { private static func stablePaletteIndex(for name: String) -> Int {
var hash: UInt64 = 1_469_598_103_934_665_603 var hash: UInt64 = 1_469_598_103_934_665_603
for scalar in name.lowercased().unicodeScalars { 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 { final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private enum Metrics { private enum Metrics {
static let actionButtonSize: CGFloat = 30 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?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor
addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor
addCollectionButton.contentTintColor = .secondaryLabelColor addCollectionButton.contentTintColor = .secondaryLabelColor
addCollectionButton.toolTip = "Add selected clip to a new collection" addCollectionButton.toolTip = "New collection"
addCollectionButton.setAccessibilityLabel("Add selected clip to a new collection") addCollectionButton.setAccessibilityLabel("New collection")
addCollectionButton.target = self addCollectionButton.target = self
addCollectionButton.action = #selector(addSelectedClipToCollection) addCollectionButton.action = #selector(createCollectionFromToolbar)
addCollectionButton.translatesAutoresizingMaskIntoConstraints = false addCollectionButton.translatesAutoresizingMaskIntoConstraints = false
addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
addCollectionButton.heightAnchor.constraint(equalToConstant: 26).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 { private func collectionColor(forCollectionNamed name: String) -> NSColor {
ClipboardCollectionVisuals.color(forCollectionNamed: name) ClipboardCollectionVisuals.color(forCollectionNamed: name, overrideHex: viewModel.collectionColorHex(named: name))
} }
private func applyCardDensity() { private func applyCardDensity() {
@@ -670,7 +710,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
isStacked: viewModel.isItemStacked(at: index), isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount, stackCount: viewModel.stackCount,
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard, canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
selectedCollectionName: viewModel.selectedCollectionName selectedCollectionName: viewModel.selectedCollectionName,
selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) }
) )
card.onSelect = { [weak self] selected in card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
@@ -812,9 +853,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
private func updateAddCollectionButtonState() { private func updateAddCollectionButtonState() {
let hasSelectedItem = viewModel.selectedItem != nil addCollectionButton.isEnabled = true
addCollectionButton.isEnabled = hasSelectedItem addCollectionButton.alphaValue = 1.0
addCollectionButton.alphaValue = hasSelectedItem ? 1.0 : 0.42
} }
private func scrollCardIntoView(_ card: NSView) { 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") { if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") {
return .ready 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 return .action
} }
if lower.hasPrefix("error") || lower.contains("failed") { 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 { if viewModel.totalItemCount == 0 {
return ( return (
"Copy something to start your history", "Copy something to start your history",
@@ -1229,6 +1276,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
if stackChip.isSelected { if stackChip.isSelected {
return stackChip.titleText 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 return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
} }
@@ -1248,7 +1298,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
var debugCustomCollectionColorHexes: [String: String] { var debugCustomCollectionColorHexes: [String: String] {
Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in 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() { func debugPressAddCollectionButton() {
addSelectedClipToCollection() createCollectionFromToolbar()
} }
func debugShowFirstCardInClipboard() { func debugShowFirstCardInClipboard() {
@@ -1373,18 +1423,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
searchField.stringValue = viewModel.searchText searchField.stringValue = viewModel.searchText
} }
@objc private func addSelectedClipToCollection() { func createCollection() {
guard viewModel.selectedItem != nil, createCollectionFromToolbar()
let name = requestCollectionName() else {
return
}
viewModel.assignSelected(to: name)
} }
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 DEBUG
if let collectionNameProviderForTesting { 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 #endif
@@ -1392,18 +1449,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
input.placeholderString = "Collection name" input.placeholderString = "Collection name"
input.stringValue = "" 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() let alert = NSAlert()
alert.messageText = "New Collection" alert.messageText = "New Collection"
alert.informativeText = "Name this collection and add the selected clip to it." alert.informativeText = "Name this collection and choose its color."
alert.accessoryView = input alert.accessoryView = stack
alert.addButton(withTitle: "Add") alert.addButton(withTitle: "Create")
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = input alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { guard alert.runModal() == .alertFirstButtonReturn,
let name = ClipboardCollectionDefaults.normalizedName(input.stringValue) else {
return nil 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, isStacked: Bool = false,
stackCount: Int = 0, stackCount: Int = 0,
canShowInClipboard: Bool = false, canShowInClipboard: Bool = false,
selectedCollectionName: String? = nil selectedCollectionName: String? = nil,
selectedCollectionColor: NSColor? = nil
) { ) {
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName) let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName) let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
@@ -1737,7 +1817,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId) self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
self.itemCollectionName = normalizedItemCollection self.itemCollectionName = normalizedItemCollection
self.activeCollectionName = activeCollection 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) } self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }
super.init(frame: .zero) super.init(frame: .zero)
configure(item: item, thumbnail: thumbnail) configure(item: item, thumbnail: thumbnail)
@@ -1925,11 +2007,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
static func debugHex(_ color: NSColor) -> String { static func debugHex(_ color: NSColor) -> String {
let rgb = color.usingColorSpace(.deviceRGB) ?? color ClipboardCollectionVisuals.hexString(for: 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)
} }
#endif #endif

View File

@@ -111,11 +111,18 @@ final class ClipboardPanelViewModel {
} }
} }
settings.observe { [weak self] change in settings.observe { [weak self] change in
guard case .captureStatus = change else { return }
self?.notifyMain { self?.notifyMain {
switch change {
case .captureStatus:
self?.statusMessage = "" self?.statusMessage = ""
self?.onStatusMessageChanged?("") self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?() self?.onCaptureStatusChanged?()
case .collections:
self?.recomputeVisibleItems()
self?.onCollectionsChanged?()
default:
break
}
} }
} }
} }
@@ -155,11 +162,20 @@ final class ClipboardPanelViewModel {
ClipboardCollectionDefaults.normalizedName(item.collectionName) ClipboardCollectionDefaults.normalizedName(item.collectionName)
} }
) )
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) } let configuredNames = settings.customCollectionNames
let customNames = assignedNames 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 { !ClipboardCollectionDefaults.names.contains($0) }
.filter { !configuredNameSet.contains($0.lowercased()) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
return defaultNames + customNames return defaultNames + configuredCustomNames + assignedCustomNames
} }
func collectionCount(for sortMode: ClipboardSortMode) -> Int { func collectionCount(for sortMode: ClipboardSortMode) -> Int {
@@ -472,6 +488,21 @@ final class ClipboardPanelViewModel {
selectedCollectionName = normalizedName 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() { func clearSearch() {
searchText = "" searchText = ""
} }
@@ -857,6 +888,9 @@ final class ClipboardPanelViewModel {
private func assign(item: ClipboardItem, to collectionName: String?) { private func assign(item: ClipboardItem, to collectionName: String?) {
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
if let normalizedName {
settings.ensureCollection(named: normalizedName)
}
store.setCollection(item.id, name: normalizedName) store.setCollection(item.id, name: normalizedName)
if let normalizedName { if let normalizedName {
statusMessage = "Added to \(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: 1, modifiers: [.command, .shift]), .toggleStack)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) 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) 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") 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() { func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -184,16 +184,10 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
} }
func testCollectionRailAddButtonCreatesCollectionForSelectedClip() { func testCollectionRailAddButtonCreatesEmptyCollection() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
XCTAssertTrue(fixture.view.debugCollectionRailContainsAddButton) 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) XCTAssertTrue(fixture.view.debugAddCollectionButtonIsEnabled)
fixture.view.debugSetCollectionNameProvider { " Research Stack " } fixture.view.debugSetCollectionNameProvider { " Research Stack " }
@@ -201,22 +195,29 @@ final class ClipboardPanelViewTests: XCTestCase {
drainMainQueue() drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded() 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.debugCustomCollectionTitles, ["Research Stack"])
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text") XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [0])
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "Research Stack - 17 characters") 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() drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"]) XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"])
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack") XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack")
XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now") XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now")
XCTAssertEqual( XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#0A9EB8")
fixture.view.debugFirstCardHeaderColorHex, XCTAssertEqual(fixture.view.debugCustomCollectionColorHexes["Research Stack"], "#0A9EB8")
fixture.view.debugCustomCollectionColorHexes["Research Stack"] ?? ""
)
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
} }

View File

@@ -25,4 +25,28 @@ final class SettingsModelTests: XCTestCase {
let restored = SettingsModel(defaults: defaults) let restored = SettingsModel(defaults: defaults)
XCTAssertTrue(restored.showDockIcon) 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")
}
} }