WIP: add empty color-coded collections
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -111,11 +111,18 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
}
|
||||
settings.observe { [weak self] change in
|
||||
guard case .captureStatus = change else { return }
|
||||
self?.notifyMain {
|
||||
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)"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user