WIP: manage collections from rail
This commit is contained in:
@@ -18,7 +18,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
|||||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
- 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, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
|
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
|
||||||
- Copy and paste actions with Accessibility permission fallback
|
- 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
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
|||||||
8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
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.
|
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.
|
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. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||||
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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||||
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. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||||
14. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
14. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
||||||
|
15. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||||
|
|
||||||
## Copy And Paste
|
## Copy And Paste
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,77 @@ final class SettingsModel {
|
|||||||
}?.value
|
}?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) -> String? {
|
||||||
|
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
|
||||||
|
let normalizedNewName = ClipboardCollectionDefaults.normalizedName(newName) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldIndex = customCollectionNames.firstIndex {
|
||||||
|
$0.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame
|
||||||
|
}
|
||||||
|
let oldCanonicalName = oldIndex.map { customCollectionNames[$0] } ?? normalizedCurrentName
|
||||||
|
let targetIndex = customCollectionNames.firstIndex {
|
||||||
|
$0.caseInsensitiveCompare(normalizedNewName) == .orderedSame
|
||||||
|
}
|
||||||
|
let targetCanonicalName: String
|
||||||
|
if let targetIndex, targetIndex != oldIndex {
|
||||||
|
targetCanonicalName = customCollectionNames[targetIndex]
|
||||||
|
} else {
|
||||||
|
targetCanonicalName = normalizedNewName
|
||||||
|
}
|
||||||
|
var changed = false
|
||||||
|
|
||||||
|
if let oldIndex {
|
||||||
|
if let targetIndex, targetIndex != oldIndex {
|
||||||
|
customCollectionNames.remove(at: oldIndex)
|
||||||
|
changed = true
|
||||||
|
} else if customCollectionNames[oldIndex] != normalizedNewName {
|
||||||
|
customCollectionNames[oldIndex] = normalizedNewName
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if targetIndex == nil {
|
||||||
|
customCollectionNames.append(normalizedNewName)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingColor = collectionColorHex(forCollectionNamed: oldCanonicalName)
|
||||||
|
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(oldCanonicalName) == .orderedSame {
|
||||||
|
collectionColorHexes.removeValue(forKey: key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
let resolvedColor = Self.normalizedHexColor(colorHex) ?? existingColor
|
||||||
|
if let resolvedColor,
|
||||||
|
collectionColorHexes[targetCanonicalName] != resolvedColor {
|
||||||
|
collectionColorHexes[targetCanonicalName] = resolvedColor
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
storeAndNotify(.collections)
|
||||||
|
}
|
||||||
|
return targetCanonicalName
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func deleteCollection(named name: String) -> String? {
|
||||||
|
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
||||||
|
var changed = false
|
||||||
|
if let index = customCollectionNames.firstIndex(where: { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }) {
|
||||||
|
customCollectionNames.remove(at: index)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(normalizedName) == .orderedSame {
|
||||||
|
collectionColorHexes.removeValue(forKey: key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
storeAndNotify(.collections)
|
||||||
|
}
|
||||||
|
return normalizedName
|
||||||
|
}
|
||||||
|
|
||||||
private static func readShortcut(from value: String?) -> ShortcutBinding? {
|
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)
|
||||||
|
|||||||
@@ -557,6 +557,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
chip.onDropItem = { [weak self] itemID in
|
chip.onDropItem = { [weak self] itemID in
|
||||||
self?.viewModel.assignItem(withID: itemID, to: collectionName)
|
self?.viewModel.assignItem(withID: itemID, to: collectionName)
|
||||||
}
|
}
|
||||||
|
chip.onEdit = { [weak self] in
|
||||||
|
self?.editCollection(named: collectionName)
|
||||||
|
}
|
||||||
|
chip.onDelete = { [weak self] in
|
||||||
|
self?.deleteCollection(named: collectionName)
|
||||||
|
}
|
||||||
customCollectionButtons[collectionName] = chip
|
customCollectionButtons[collectionName] = chip
|
||||||
collectionStack.addArrangedSubview(chip)
|
collectionStack.addArrangedSubview(chip)
|
||||||
}
|
}
|
||||||
@@ -1347,6 +1353,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
createCollectionFromToolbar()
|
createCollectionFromToolbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] {
|
||||||
|
customCollectionButtons[collectionName]?.debugMenuTitles ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugEditCollection(named collectionName: String, to newName: String, colorHex: String) {
|
||||||
|
viewModel.updateCollection(named: collectionName, to: newName, colorHex: colorHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugDeleteCollection(named collectionName: String) {
|
||||||
|
viewModel.deleteCollection(named: collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
func debugShowFirstCardInClipboard() {
|
func debugShowFirstCardInClipboard() {
|
||||||
showSelectedInClipboard(at: 0)
|
showSelectedInClipboard(at: 0)
|
||||||
}
|
}
|
||||||
@@ -1432,6 +1450,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
|
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func editCollection(named collectionName: String) {
|
||||||
|
guard let request = requestCollectionEdit(named: collectionName) else { return }
|
||||||
|
viewModel.updateCollection(named: collectionName, to: request.name, colorHex: request.colorHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteCollection(named collectionName: String) {
|
||||||
|
let count = viewModel.collectionCount(named: collectionName)
|
||||||
|
guard confirmDeleteCollection(named: collectionName, count: count) else { return }
|
||||||
|
viewModel.deleteCollection(named: collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
private func requestCollectionCreation() -> CollectionCreationRequest? {
|
private func requestCollectionCreation() -> CollectionCreationRequest? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let collectionNameProviderForTesting {
|
if let collectionNameProviderForTesting {
|
||||||
@@ -1445,12 +1474,38 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
return requestCollectionDetails(
|
||||||
|
title: "New Collection",
|
||||||
|
message: "Name this collection and choose its color.",
|
||||||
|
actionTitle: "Create",
|
||||||
|
initialName: "",
|
||||||
|
initialColor: ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestCollectionEdit(named collectionName: String) -> CollectionCreationRequest? {
|
||||||
|
requestCollectionDetails(
|
||||||
|
title: "Edit Collection",
|
||||||
|
message: "Update this collection's name and color.",
|
||||||
|
actionTitle: "Save",
|
||||||
|
initialName: collectionName,
|
||||||
|
initialColor: collectionColor(forCollectionNamed: collectionName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestCollectionDetails(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
actionTitle: String,
|
||||||
|
initialName: String,
|
||||||
|
initialColor: NSColor
|
||||||
|
) -> CollectionCreationRequest? {
|
||||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||||
input.placeholderString = "Collection name"
|
input.placeholderString = "Collection name"
|
||||||
input.stringValue = ""
|
input.stringValue = initialName
|
||||||
|
|
||||||
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
|
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
|
||||||
colorWell.color = ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
|
colorWell.color = initialColor
|
||||||
|
|
||||||
let colorLabel = NSTextField(labelWithString: "Color")
|
let colorLabel = NSTextField(labelWithString: "Color")
|
||||||
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
||||||
@@ -1468,10 +1523,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
|
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
|
||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "New Collection"
|
alert.messageText = title
|
||||||
alert.informativeText = "Name this collection and choose its color."
|
alert.informativeText = message
|
||||||
alert.accessoryView = stack
|
alert.accessoryView = stack
|
||||||
alert.addButton(withTitle: "Create")
|
alert.addButton(withTitle: actionTitle)
|
||||||
alert.addButton(withTitle: "Cancel")
|
alert.addButton(withTitle: "Cancel")
|
||||||
alert.window.initialFirstResponder = input
|
alert.window.initialFirstResponder = input
|
||||||
|
|
||||||
@@ -1484,6 +1539,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
|
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func confirmDeleteCollection(named collectionName: String, count: Int) -> Bool {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Delete \(collectionName)?"
|
||||||
|
let noun = count == 1 ? "clip" : "clips"
|
||||||
|
alert.informativeText = count > 0
|
||||||
|
? "This removes \(count) \(noun) in this collection from clipboard history."
|
||||||
|
: "This removes the empty collection."
|
||||||
|
alert.addButton(withTitle: "Delete")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
return alert.runModal() == .alertFirstButtonReturn
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ClipboardItemDragPasteboard {
|
private enum ClipboardItemDragPasteboard {
|
||||||
@@ -1516,6 +1584,8 @@ private final class CollectionChipView: NSView {
|
|||||||
private var isDropTargeted = false
|
private var isDropTargeted = false
|
||||||
var onPress: () -> Void = {}
|
var onPress: () -> Void = {}
|
||||||
var onDropItem: ((UUID) -> Void)?
|
var onDropItem: ((UUID) -> Void)?
|
||||||
|
var onEdit: (() -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
init(title: String, color: NSColor) {
|
init(title: String, color: NSColor) {
|
||||||
self.titleText = title
|
self.titleText = title
|
||||||
@@ -1630,6 +1700,38 @@ private final class CollectionChipView: NSView {
|
|||||||
onPress()
|
onPress()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func menu(for event: NSEvent) -> NSMenu? {
|
||||||
|
guard onEdit != nil || onDelete != nil else { return nil }
|
||||||
|
return contextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contextMenu() -> NSMenu {
|
||||||
|
let menu = NSMenu(title: titleText)
|
||||||
|
menu.autoenablesItems = false
|
||||||
|
if onEdit != nil {
|
||||||
|
let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "")
|
||||||
|
item.target = self
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
if onDelete != nil {
|
||||||
|
if !menu.items.isEmpty {
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
}
|
||||||
|
let item = NSMenuItem(title: "Delete Collection", action: #selector(deleteFromMenu), keyEquivalent: "")
|
||||||
|
item.target = self
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func editFromMenu() {
|
||||||
|
onEdit?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteFromMenu() {
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
|
||||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||||
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
|
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
|
||||||
setDropTargeted(true)
|
setDropTargeted(true)
|
||||||
@@ -1682,6 +1784,10 @@ private final class CollectionChipView: NSView {
|
|||||||
func debugDropItem(_ itemID: UUID) {
|
func debugDropItem(_ itemID: UUID) {
|
||||||
onDropItem?(itemID)
|
onDropItem?(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugMenuTitles: [String] {
|
||||||
|
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,6 +503,39 @@ final class ClipboardPanelViewModel {
|
|||||||
settings.collectionColorHex(forCollectionNamed: name)
|
settings.collectionColorHex(forCollectionNamed: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) {
|
||||||
|
guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName),
|
||||||
|
let normalizedNewName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
|
||||||
|
store.setCollection(item.id, name: normalizedNewName)
|
||||||
|
}
|
||||||
|
if selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame {
|
||||||
|
selectedCollectionName = normalizedNewName
|
||||||
|
} else {
|
||||||
|
recomputeVisibleItems()
|
||||||
|
}
|
||||||
|
statusMessage = "Updated \(normalizedNewName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCollection(named name: String) {
|
||||||
|
guard let normalizedName = settings.deleteCollection(named: name) else { return }
|
||||||
|
let matchingIDs = items
|
||||||
|
.filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame }
|
||||||
|
.map(\.id)
|
||||||
|
for id in matchingIDs {
|
||||||
|
store.remove(id)
|
||||||
|
}
|
||||||
|
if selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame {
|
||||||
|
selectedCollectionName = nil
|
||||||
|
} else {
|
||||||
|
recomputeVisibleItems()
|
||||||
|
}
|
||||||
|
statusMessage = "Deleted \(normalizedName)"
|
||||||
|
}
|
||||||
|
|
||||||
func clearSearch() {
|
func clearSearch() {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,57 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8")
|
XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUpdateCollectionRenamesAssignedItemsAndColor() {
|
||||||
|
let settings = makeSettings()
|
||||||
|
let cacheService = makeCacheService()
|
||||||
|
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||||
|
var research = makeTextItem("research note", createdAt: Date(timeIntervalSince1970: 100))
|
||||||
|
research.collectionName = "Research Stack"
|
||||||
|
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
|
||||||
|
store.upsert(research)
|
||||||
|
store.upsert(outside)
|
||||||
|
store.flushPersistenceForTesting()
|
||||||
|
|
||||||
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
|
waitForVisibleItems(in: viewModel, count: 2)
|
||||||
|
viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||||
|
|
||||||
|
viewModel.updateCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
|
||||||
|
store.flushPersistenceForTesting()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.collectionNames, ["Product Research"])
|
||||||
|
XCTAssertEqual(viewModel.collectionColorHex(named: "Product Research"), "#3366FF")
|
||||||
|
XCTAssertEqual(viewModel.selectedCollectionName, "Product Research")
|
||||||
|
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["research note"])
|
||||||
|
XCTAssertEqual(viewModel.statusMessage, "Updated Product Research")
|
||||||
|
XCTAssertEqual(store.items.first(where: { $0.payload == "research note" })?.collectionName, "Product Research")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteCollectionRemovesCollectionItemsFromHistory() {
|
||||||
|
let settings = makeSettings()
|
||||||
|
let cacheService = makeCacheService()
|
||||||
|
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||||
|
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
|
||||||
|
client.collectionName = "Client Work"
|
||||||
|
let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))
|
||||||
|
store.upsert(client)
|
||||||
|
store.upsert(outside)
|
||||||
|
store.flushPersistenceForTesting()
|
||||||
|
|
||||||
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
|
waitForVisibleItems(in: viewModel, count: 2)
|
||||||
|
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8")
|
||||||
|
|
||||||
|
viewModel.deleteCollection(named: "Client Work")
|
||||||
|
store.flushPersistenceForTesting()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.collectionNames, [])
|
||||||
|
XCTAssertNil(viewModel.selectedCollectionName)
|
||||||
|
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note"])
|
||||||
|
XCTAssertEqual(store.items.map(\.payload), ["outside note"])
|
||||||
|
XCTAssertEqual(viewModel.statusMessage, "Deleted Client Work")
|
||||||
|
}
|
||||||
|
|
||||||
func testSearchTextRecomputesVisibleItemsImmediately() {
|
func testSearchTextRecomputesVisibleItemsImmediately() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let cacheService = makeCacheService()
|
let cacheService = makeCacheService()
|
||||||
|
|||||||
@@ -221,6 +221,42 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
|
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCollectionChipsExposeManagementMenuActions() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(fixture.view.debugCustomCollectionMenuTitles(named: "Research Stack"), ["Edit Collection...", "-", "Delete Collection"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCollectionChipManagementRenamesAndDeletesCollections() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||||
|
var item = makeTextItem("Collect this note", store: fixture.store)
|
||||||
|
item.collectionName = "Research Stack"
|
||||||
|
fixture.store.upsert(item)
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
fixture.view.debugEditCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF")
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Product Research"])
|
||||||
|
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Product Research")
|
||||||
|
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Product Research")
|
||||||
|
XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF")
|
||||||
|
|
||||||
|
fixture.view.debugDeleteCollection(named: "Product Research")
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, [])
|
||||||
|
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), [])
|
||||||
|
XCTAssertEqual(fixture.store.items.map(\.payload), [])
|
||||||
|
}
|
||||||
|
|
||||||
func testSelectedCardActionsRespectSelectedKind() {
|
func testSelectedCardActionsRespectSelectedKind() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
|
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
|
||||||
|
|||||||
@@ -49,4 +49,32 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"])
|
XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"])
|
||||||
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355")
|
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCustomCollectionsCanBeUpdatedAndDeleted() {
|
||||||
|
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
let settings = SettingsModel(defaults: defaults)
|
||||||
|
|
||||||
|
settings.ensureCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||||
|
settings.ensureCollection(named: "Client Work", colorHex: "#FF3355")
|
||||||
|
|
||||||
|
let updatedName = settings.updateCollection(named: "research stack", to: "Product Research", colorHex: "#3366FF")
|
||||||
|
|
||||||
|
XCTAssertEqual(updatedName, "Product Research")
|
||||||
|
XCTAssertEqual(settings.customCollectionNames, ["Product Research", "Client Work"])
|
||||||
|
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Research Stack"))
|
||||||
|
XCTAssertEqual(settings.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||||
|
|
||||||
|
settings.deleteCollection(named: "client work")
|
||||||
|
|
||||||
|
XCTAssertEqual(settings.customCollectionNames, ["Product Research"])
|
||||||
|
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Client Work"))
|
||||||
|
|
||||||
|
let restored = SettingsModel(defaults: defaults)
|
||||||
|
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
|
||||||
|
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user