diff --git a/AGENTS.md b/AGENTS.md index 71ee9d0..a390f1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,14 @@ brew upgrade --cask prompt ## Gotchas +- **Setting `NSApp.mainMenu` replaces SwiftUI's default menu.** SwiftUI installs + a menu containing the standard Edit items, and AppKit routes Cmd+C/V/X/A/Z + through those items — so a hand-built menu with only Hide/Quit silently + breaks pasting, copying and select-all in the composer. `setupMainMenu()` + therefore rebuilds an Edit menu with `undo:`/`redo:`/`cut:`/`copy:`/`paste:`/ + `selectAll:` (nil targets, so they travel the responder chain to the field + editor). `Prompt --diag` dumps the menu so this can be checked without a + keyboard. - **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 cb4fd28..20084c5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ to your work. Click anything to copy it back to your clipboard. - **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. -- **Sections** — type `# Name` in the composer; Cmd+K switches sections. +- **Workspaces** — type `# Name` in the composer, or use "New Workspace" in + 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. @@ -25,7 +28,7 @@ to your work. Click anything to copy it back to your clipboard. | --- | --- | | Capture current selection | Left Shift ×2 (or Cmd+Shift+C) | | Show/hide panel | Right Shift ×2 (or Cmd+Shift+Space) | -| Switch/create section | Cmd+K | +| Switch/create workspace | Cmd+K | | Search | Cmd+F | | Focus composer | Cmd+N | | Sync settings | Cmd+, | diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift index 5dd83b0..0b1b5be 100644 --- a/Sources/Prompt/App.swift +++ b/Sources/Prompt/App.swift @@ -32,6 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // which locks the user out once the panel is hidden. if CommandLine.arguments.contains("--diag") { setupStatusItem() + setupMainMenu() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.writeDiagnostics() NSApp.terminate(nil) @@ -113,24 +114,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Accessory apps have no visible menu bar, but the main menu still /// services key equivalents — this is what makes Cmd+Q and Cmd+W work /// while the panel has focus. + /// + /// The Edit menu is not decoration: AppKit routes Cmd+C/V/X/A/Z through + /// these items, so a minimal main menu silently breaks pasting and + /// selecting in the composer. private func setupMainMenu() { let mainMenu = NSMenu() + let appMenuItem = NSMenuItem() mainMenu.addItem(appMenuItem) - - let appMenu = NSMenu() + let appMenu = NSMenu(title: "Prompt") appMenuItem.submenu = appMenu let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") hide.target = self appMenu.addItem(hide) - appMenu.addItem(.separator()) - let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q") quit.target = self appMenu.addItem(quit) + let editMenuItem = NSMenuItem() + mainMenu.addItem(editMenuItem) + let editMenu = NSMenu(title: "Edit") + editMenuItem.submenu = editMenu + + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z") + redo.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + NSApp.mainMenu = mainMenu } @@ -153,6 +170,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { lines.append("status item has no window (not in the menu bar)") } lines.append("preferred position: \(UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") ?? "unset")") + lines.append("main menu:") + for item in NSApp.mainMenu?.items ?? [] { + lines.append(" \(item.submenu?.title ?? item.title)") + for entry in item.submenu?.items ?? [] { + guard !entry.isSeparatorItem else { continue } + let action = entry.action.map(NSStringFromSelector) ?? "-" + lines.append(" \(entry.title) [\(entry.keyEquivalentModifierMask.rawValue)] \(action)") + } + } try? lines.joined(separator: "\n").write( to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8 ) diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 84279ed..05fc662 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -10,6 +10,8 @@ struct PanelView: View { @State private var search = "" @State private var showSearch = false @State private var showSectionSwitcher = false + @State private var showNewWorkspace = false + @State private var newWorkspaceName = "" @State private var showSync = false @State private var joinCode = "" @State private var selection = Set() @@ -17,7 +19,7 @@ struct PanelView: View { @State private var copiedID: UUID? @State private var accessibilityGranted = AXIsProcessTrusted() - private enum Field: Hashable { case composer, search } + private enum Field: Hashable { case composer, search, newWorkspace } @FocusState private var focusedField: Field? var body: some View { @@ -41,6 +43,7 @@ struct PanelView: View { .background(shortcutButtons) .overlay(sectionSwitcher) .overlay(syncOverlay) + .overlay(newWorkspaceModal) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in focusedField = .composer @@ -66,7 +69,7 @@ struct PanelView: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Switch section (Cmd+K)") + .help("Switch workspace (Cmd+K)") Spacer() @@ -113,7 +116,7 @@ struct PanelView: View { private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Search all sections", text: $search) + TextField("Search all workspaces", text: $search) .textFieldStyle(.plain) .focused($focusedField, equals: .search) .onExitCommand { @@ -315,39 +318,57 @@ struct PanelView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(spacing: 0) { - ForEach(store.sections) { section in - Button { + ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in + HStack(spacing: 6) { + Text(section.name) + .font(.callout) + Spacer() + Text("\(section.items.count)") + .font(.caption) + .foregroundStyle(.tertiary) + if section.id == store.activeSection.id { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(Color.accentColor) + } + // Reorder the workspace list; the boundary rows are + // disabled rather than hidden so the list does not jump. + HStack(spacing: 0) { + Button { + store.moveSection(section.id, by: -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(index == 0) + Button { + store.moveSection(section.id, by: 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(index == store.sections.count - 1) + } + .buttonStyle(.plain) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { store.activeSectionID = section.id showSectionSwitcher = false - } label: { - HStack { - Text(section.name) - .font(.callout) - Spacer() - Text("\(section.items.count)") - .font(.caption) - .foregroundStyle(.tertiary) - if section.id == store.activeSection.id { - Image(systemName: "checkmark") - .font(.caption) - .foregroundStyle(Color.accentColor) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .contentShape(Rectangle()) } - .buttonStyle(.plain) } Divider() Button { - composer = "# " showSectionSwitcher = false - focusedField = .composer + newWorkspaceName = "" + showNewWorkspace = true + focusedField = .newWorkspace } label: { HStack { Image(systemName: "plus") - Text("New Section") + Text("New Workspace") Spacer() } .padding(.horizontal, 14) @@ -465,6 +486,53 @@ struct PanelView: View { } } + // MARK: - New workspace + + @ViewBuilder + private var newWorkspaceModal: some View { + if showNewWorkspace { + ZStack { + Color.black.opacity(0.25) + .onTapGesture { dismissNewWorkspace() } + VStack(alignment: .leading, spacing: 12) { + Text("New Workspace") + .font(.headline) + TextField("Name", text: $newWorkspaceName) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .newWorkspace) + .onSubmit(createWorkspace) + .onExitCommand { dismissNewWorkspace() } + HStack { + Spacer() + Button("Cancel") { dismissNewWorkspace() } + Button("Create") { createWorkspace() } + .keyboardShortcut(.defaultAction) + .disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(16) + .frame(width: 260) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(radius: 18) + } + .transition(.opacity) + } + } + + private func createWorkspace() { + let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + store.createSection(named: name) + dismissNewWorkspace() + } + + private func dismissNewWorkspace() { + showNewWorkspace = false + newWorkspaceName = "" + focusedField = .composer + } + // MARK: - Selection private var selectionBar: some View { diff --git a/Sources/Prompt/Store.swift b/Sources/Prompt/Store.swift index f8a8dc6..51283bd 100644 --- a/Sources/Prompt/Store.swift +++ b/Sources/Prompt/Store.swift @@ -38,11 +38,17 @@ final class Store: ObservableObject { private let storeFile: URL private let imageCache = NSCache() - init() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - directory = appSupport.appendingPathComponent("Prompt", isDirectory: true) - imagesDirectory = directory.appendingPathComponent("Images", isDirectory: true) - storeFile = directory.appendingPathComponent("store.json") + /// `directory` is injectable so the store can be exercised against a + /// scratch folder instead of the user's real data. + init(directory: URL? = nil) { + if let directory { + self.directory = directory + } else { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.directory = appSupport.appendingPathComponent("Prompt", isDirectory: true) + } + imagesDirectory = self.directory.appendingPathComponent("Images", isDirectory: true) + storeFile = self.directory.appendingPathComponent("store.json") try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) load() @@ -130,6 +136,18 @@ final class Store: ObservableObject { return section } + /// Reorders a workspace within the list. The UI calls these "workspaces"; + /// the model and the persisted JSON keep the original `sections` naming so + /// existing stores and sync payloads keep working. + func moveSection(_ id: UUID, by offset: Int) { + guard let index = sections.firstIndex(where: { $0.id == id }) else { return } + let target = index + offset + guard sections.indices.contains(target) else { return } + let section = sections.remove(at: index) + sections.insert(section, at: target) + save() + } + // MARK: - Items @discardableResult