diff --git a/AGENTS.md b/AGENTS.md index cb37ea1..7758a56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,13 +27,17 @@ clicking an item copies it back to the clipboard. `Images/` next to it, cached in an `NSCache`. Owns all mutations (add, toggle done, edit, delete, move, merge, clear done) and clipboard writes (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines; - both text modes skip image items). Saves on every mutation. + both text modes skip image items). Batch mutations (`delete(ids:)`, + `setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one + action applies to every selected item and image files are cleaned up once. + Saves on every mutation. - `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, search, add photos, close), item list, composer. Composer `# Name` creates/ - switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click - selects a range, and a selection bar offers Copy as List / Copy as Lines; - right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit, - Open, Merge, Move To, Delete). Esc clears the selection before it hides the + switches sections. Click = copy; the row checkbox selects, Cmd+click toggles + multi-select, Shift+click selects a range, and a selection bar offers Copy as + List / Copy as Lines; right-click = context menu acting on the selection + (Copy, Copy as List, Copy as Lines, Done, Edit, Open, Merge, Move To, + Delete). Esc clears the selection before it hides the panel. Drop target for images/files. - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as @@ -169,8 +173,10 @@ brew upgrade --cask prompt clipboard image to the store when there is no text. `Prompt --diag` dumps the menu so the wiring can be checked without a keyboard. - **A row is not a `Button`** because it contains a checkbox `Button` for - ticking items off, and nested buttons never receive clicks. The row uses - `contentShape` + `onTapGesture` for click-to-copy instead. + selecting the item, and nested buttons never receive clicks. The row uses + `contentShape` + `onTapGesture` for click-to-copy instead. Selection is + driven by the checkbox, Cmd+click (toggle) and Shift+click (range); the + context menu acts on the whole selection via `effectiveIDs(for:)`. - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, plain Cmd+C). SwiftUI key equivalents are matched before text input and would steal characters from the composer TextField. Only Cmd-modified diff --git a/README.md b/README.md index ebb5fc9..a22eca6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ A native macOS app for keeping prompts and photos, in the spirit of [Copper](https://shadcn.com/copper) by shadcn: a floating panel that sits next to your work. Click anything to copy it back to your clipboard. -- **Checklists by default** — every item has a checkbox, so a workspace doubles - as a to-do list; done items stay in place, dimmed and struck through. +- **Checkbox to select** — every item has a checkbox; tick several and act on + them at once (copy, delete, move, merge). Clicking the row itself still + copies that item. - **Click to copy** — text notes go back as text, photos go back as images. - **Capture from anywhere** — select text in any app and tap **Left Shift twice**. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button. @@ -13,9 +14,11 @@ to your work. Click anything to copy it back to your clipboard. the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the up/down arrows in the switcher reorder them. (The code and the saved JSON still call these `sections` so existing data keeps working.) -- **Copy as List / Copy as Lines** — Cmd+click to multi-select (Shift+click for - a range), then copy the selection as a numbered list or as plain - newline-separated text, from the selection bar or the right-click menu. +- **Copy as List / Copy as Lines** — select with the checkboxes (or Cmd+click, + or Shift+click for a range), then copy the selection as a numbered list or as + plain newline-separated text, from the selection bar, the right-click menu, or + Shift+Cmd+C / Option+Cmd+C. The same menu deletes, moves or merges the + selection. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Search** — Cmd+F searches across all sections. - **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 44ef165..06e64f9 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -193,20 +193,20 @@ struct PanelView: View { .padding(.vertical, 48) } - /// Rows are checklists by default: a checkbox to tick an item off, with the - /// rest of the row copying on click. The row cannot be a Button because the - /// checkbox is one, and nested buttons do not receive clicks. + /// Rows carry a checkbox that *selects* the item, so a selection can be + /// copied or deleted from the context menu. The row cannot be a Button + /// because the checkbox is one, and nested buttons do not receive clicks. private func row(for item: PromptItem, sectionName: String) -> some View { HStack(alignment: .top, spacing: 8) { Button { - store.toggleDone(item.id) + toggleSelection(item.id) } label: { - Image(systemName: item.done ? "checkmark.circle.fill" : "circle") + Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle") .font(.system(size: 15)) - .foregroundStyle(item.done ? Color.accentColor : Color.secondary.opacity(0.55)) + .foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55)) } .buttonStyle(.plain) - .help(item.done ? "Mark as not done" : "Mark as done") + .help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete") VStack(alignment: .leading, spacing: 6) { if !sectionName.isEmpty { @@ -278,43 +278,51 @@ struct PanelView: View { } private func contextMenu(for item: PromptItem) -> some View { - Group { + let ids = effectiveIDs(for: item) + let multiple = ids.count > 1 + return Group { Button("Copy") { copy(item) } - Button("Copy as List") { - store.copyAsList(ids: effectiveIDs(for: item)) + Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") { + store.copyAsList(ids: ids) flashCopied(item.id) } - Button("Copy as Lines") { - store.copyAsLines(ids: effectiveIDs(for: item)) + Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") { + store.copyAsLines(ids: ids) flashCopied(item.id) } Divider() - Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) } - if case .text(let text) = item.content { + Button(item.done ? "Mark as Not Done" : "Mark as Done") { + store.setDone(!item.done, ids: ids) + } + if case .text(let text) = item.content, !multiple { Button("Edit…") { PanelController.shared.editController.edit(itemID: item.id, text: text) } } - if case .image(let filename) = item.content { + if case .image(let filename) = item.content, !multiple { Button("Open") { NSWorkspace.shared.open(store.imageURL(for: filename)) } } - if selection.count > 1, selection.contains(item.id) { + if multiple { Button("Merge Notes") { - store.merge(ids: selection) + store.merge(ids: ids) clearSelection() } } Menu("Move To") { ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in - Button(section.name) { store.move(item.id, to: section.id) } + Button(section.name) { + store.move(ids: ids, to: section.id) + if multiple { clearSelection() } + } } } Divider() - Button("Delete", role: .destructive) { - selection.remove(item.id) - store.delete(item.id) + Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) { + store.delete(ids: ids) + selection.subtract(ids) + lastSelectedID = nil } } } @@ -664,12 +672,7 @@ struct PanelView: View { private func handleClick(_ item: PromptItem) { let modifiers = NSApp.currentEvent?.modifierFlags ?? [] if modifiers.contains(.command) { - if selection.contains(item.id) { - selection.remove(item.id) - } else { - selection.insert(item.id) - } - lastSelectedID = item.id + toggleSelection(item.id) return } if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id { @@ -679,6 +682,15 @@ struct PanelView: View { copy(item) } + private func toggleSelection(_ id: UUID) { + if selection.contains(id) { + selection.remove(id) + } else { + selection.insert(id) + } + lastSelectedID = id + } + private func selectRange(from anchor: UUID, to target: UUID) { let ids = displayedItems.map(\.item.id) guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return } diff --git a/Sources/Prompt/Store.swift b/Sources/Prompt/Store.swift index 51283bd..439f13b 100644 --- a/Sources/Prompt/Store.swift +++ b/Sources/Prompt/Store.swift @@ -190,18 +190,49 @@ final class Store: ObservableObject { } func delete(_ id: UUID) { + delete(ids: [id]) + } + + /// Deletes every item in `ids` in one pass, cleaning up their image files. + func delete(ids: Set) { + guard !ids.isEmpty else { return } + var removedAny = false for sectionIndex in sections.indices { - if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { - let item = sections[sectionIndex].items[itemIndex] + for item in sections[sectionIndex].items where ids.contains(item.id) { + removedAny = true if case .image(let filename) = item.content { try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename)) imageCache.removeObject(forKey: filename as NSString) } - sections[sectionIndex].items.remove(at: itemIndex) - save() - return + } + sections[sectionIndex].items.removeAll { ids.contains($0.id) } + } + if removedAny { save() } + } + + func setDone(_ done: Bool, ids: Set) { + guard !ids.isEmpty else { return } + for sectionIndex in sections.indices { + for itemIndex in sections[sectionIndex].items.indices + where ids.contains(sections[sectionIndex].items[itemIndex].id) { + sections[sectionIndex].items[itemIndex].done = done } } + save() + } + + /// Moves every item in `ids` into `targetSectionID`, keeping their order. + func move(ids: Set, to targetSectionID: UUID) { + guard !ids.isEmpty, + let targetIndex = sections.firstIndex(where: { $0.id == targetSectionID }) else { return } + var moved: [PromptItem] = [] + for sectionIndex in sections.indices where sectionIndex != targetIndex { + moved.append(contentsOf: sections[sectionIndex].items.filter { ids.contains($0.id) }) + sections[sectionIndex].items.removeAll { ids.contains($0.id) } + } + guard !moved.isEmpty else { return } + sections[targetIndex].items.append(contentsOf: moved) + save() } func move(_ id: UUID, to targetSectionID: UUID) {