WIP: manage collections from rail
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user