WIP: manage collections from rail

This commit is contained in:
Akshay Kolli
2026-06-30 04:01:57 -07:00
parent 16e2200244
commit 3e38514387
8 changed files with 336 additions and 10 deletions

View File

@@ -252,6 +252,77 @@ final class SettingsModel {
}?.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? {
guard let value else { return nil }
return ShortcutBinding(encoded: value)

View File

@@ -557,6 +557,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.onDropItem = { [weak self] itemID in
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
collectionStack.addArrangedSubview(chip)
}
@@ -1347,6 +1353,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
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() {
showSelectedInClipboard(at: 0)
}
@@ -1432,6 +1450,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
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? {
#if DEBUG
if let collectionNameProviderForTesting {
@@ -1445,12 +1474,38 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
#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))
input.placeholderString = "Collection name"
input.stringValue = ""
input.stringValue = initialName
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")
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)
let alert = NSAlert()
alert.messageText = "New Collection"
alert.informativeText = "Name this collection and choose its color."
alert.messageText = title
alert.informativeText = message
alert.accessoryView = stack
alert.addButton(withTitle: "Create")
alert.addButton(withTitle: actionTitle)
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = input
@@ -1484,6 +1539,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
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 {
@@ -1516,6 +1584,8 @@ private final class CollectionChipView: NSView {
private var isDropTargeted = false
var onPress: () -> Void = {}
var onDropItem: ((UUID) -> Void)?
var onEdit: (() -> Void)?
var onDelete: (() -> Void)?
init(title: String, color: NSColor) {
self.titleText = title
@@ -1630,6 +1700,38 @@ private final class CollectionChipView: NSView {
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 {
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
setDropTargeted(true)
@@ -1682,6 +1784,10 @@ private final class CollectionChipView: NSView {
func debugDropItem(_ itemID: UUID) {
onDropItem?(itemID)
}
var debugMenuTitles: [String] {
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
}
#endif
}

View File

@@ -503,6 +503,39 @@ final class ClipboardPanelViewModel {
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() {
searchText = ""
}