4 Commits
Author SHA1 Message Date
TZGyn 93cd6f2056 Restore pasting and rename sections to workspaces
Replacing the main menu dropped the Edit menu that AppKit routes
Cmd+V/C/A/Z through, so the composer could not paste at all. The menu is
rebuilt with those items, which --diag now dumps so the wiring can be
checked without a keyboard.

Workspaces also gained an order: the Cmd+K switcher has up/down controls,
creating one opens a naming modal instead of prefilling the composer, and
the UI now says workspace throughout. The stored JSON keeps the original
`sections` keys so existing data and sync payloads stay valid.
2026-10-09 17:29:16 +08:00
TZGyn c5011d03e7 Let a multi-selection be copied as plain lines as well as a list
Copying several notes only produced a numbered list, which is wrong for
pasting into a prompt that already has structure. The selection now
offers both forms, Shift+click selects a range, and a selection bar
makes it obvious that more than one note is selected. Esc clears the
selection before it hides the panel.
2026-10-09 17:18:04 +08:00
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
Closing the panel only hid it, and launching the app again did nothing
because the running instance ignored the reopen, so it looked like the
app refused to exit. Reopening now shows the panel, and the main menu
makes Cmd+Q and Cmd+W work while the panel has focus. Termination also
tears down the event tap, hotkeys and sync timers instead of leaving
them live.
2026-10-08 10:43:42 +08:00
8 changed files with 436 additions and 70 deletions
+28 -7
View File
@@ -13,19 +13,28 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel, `NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem` folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). (`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still
services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
launching an already-running app does something. Supports `--sync-check` and
`--diag` (writes the status item's frame and permission state to
`/tmp/prompt-diag.txt`, then exits).
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton. - `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
Data model: `Section` → `[PromptItem]`, item content is Data model: `Section` → `[PromptItem]`, item content is
`.text(String)` or `.image(String)` (filename). Persists to `.text(String)` or `.image(String)` (filename). Persists to
`~/Library/Application Support/Prompt/store.json`; images are PNG files in `~/Library/Application Support/Prompt/store.json`; images are PNG files in
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add, `Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
toggle done, edit, delete, move, merge, clear done) and clipboard writes toggle done, edit, delete, move, merge, clear done) and clipboard writes
(`copy`, `copyAsList`). Saves on every mutation. (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines;
both text modes skip image items). Saves on every mutation.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, - `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
search, add photos, close), item list, composer. Composer `# Name` creates/ search, add photos, close), item list, composer. Composer `# Name` creates/
switches sections. Click = copy; Cmd+click = multi-select; right-click = switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click
context menu (Copy, Copy as List, Done, Edit, Open, Merge, Move To, Delete). selects a range, and a selection bar offers Copy as List / Copy as Lines;
Drop target for images/files. 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
panel. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController` `PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
@@ -147,10 +156,19 @@ brew upgrade --cask prompt
## Gotchas ## 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, - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete,
plain Cmd+C). SwiftUI key equivalents are matched before text input and plain Cmd+C). SwiftUI key equivalents are matched before text input and
would steal characters from the composer TextField. Only Cmd-modified would steal characters from the composer TextField. Only Cmd-modified
shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C). shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C,
Option+Cmd+C).
This is why Copy has no in-panel shortcut: click-to-copy is the primary This is why Copy has no in-panel shortcut: click-to-copy is the primary
gesture, matching Copper's intent. gesture, matching Copper's intent.
- The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what - The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what
@@ -167,7 +185,10 @@ brew upgrade --cask prompt
the main thread (the hotkey handler already dispatches to a global queue). the main thread (the hotkey handler already dispatches to a global queue).
- The double-Shift tap needs Accessibility; without it the event tap silently - The double-Shift tap needs Accessibility; without it the event tap silently
fails and only the Carbon fallbacks work. The panel banner and the status fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state. menu both surface the permission state, and `AppDelegate` shows a launch
alert when it is missing — otherwise a user who hides the panel is locked out
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
with `Prompt --diag` (`accessibility: false` is the tell).
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is - `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
would otherwise block it with a confusing error. would otherwise block it with a confusing error.
+15 -8
View File
@@ -7,9 +7,13 @@ 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. - **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**. - **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. - **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
- **Copy as List** — Cmd+click to multi-select, right-click → Copy as List the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
(or Shift+Cmd+C) for a numbered list ready to paste into a chat. 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.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections. - **Search** — Cmd+F searches across all sections.
- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and - **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and
@@ -24,18 +28,21 @@ to your work. Click anything to copy it back to your clipboard.
| --- | --- | | --- | --- |
| Capture current selection | Left Shift ×2 (or Cmd+Shift+C) | | Capture current selection | Left Shift ×2 (or Cmd+Shift+C) |
| Show/hide panel | Right Shift ×2 (or Cmd+Shift+Space) | | Show/hide panel | Right Shift ×2 (or Cmd+Shift+Space) |
| Switch/create section | Cmd+K | | Switch/create workspace | Cmd+K |
| Search | Cmd+F | | Search | Cmd+F |
| Focus composer | Cmd+N | | Focus composer | Cmd+N |
| Sync settings | Cmd+, | | Sync settings | Cmd+, |
| Copy selection as numbered list | Shift+Cmd+C | | Copy selection as numbered list | Shift+Cmd+C |
| Copy selection as plain lines | Option+Cmd+C |
| Clear selection | Esc |
| Close panel | Esc | | Close panel | Esc |
Double-Shift gestures require **Accessibility access** (Prompt reads the Double-Shift gestures require **Accessibility access** (Prompt reads the
selection by simulating Cmd+C, then restores your clipboard untouched). The selection by simulating Cmd+C, then restores your clipboard untouched). Until
Cmd+Shift fallbacks work without it. Bare keys like Space and Cmd+C are it is granted, use Cmd+Shift+Space and Cmd+Shift+C, or click the menu bar icon
deliberately not bound inside the panel so they never conflict with typing in — and launching the app again always brings the panel back. Bare keys like
the composer. Space and Cmd+C are deliberately not bound inside the panel so they never
conflict with typing in the composer.
## Install ## Install
+128
View File
@@ -27,6 +27,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
// --diag reports where the status item landed. On a notched Mac a
// brand-new item can be placed under the notch and be unclickable,
// 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)
}
return
}
// Single instance: stale copies keep their hotkeys alive after an // Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance. // update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier let myPid = ProcessInfo.processInfo.processIdentifier
@@ -47,6 +60,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded()
// Show the panel on launch so the app is discoverable (it has no Dock // Show the panel on launch so the app is discoverable (it has no Dock
// icon and the menu bar icon can be hard to spot on a full bar). // icon and the menu bar icon can be hard to spot on a full bar).
@@ -56,6 +71,119 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// Without Accessibility the Shift-Shift gestures fail silently, so the
/// panel can be hidden with no obvious way back. Say so on launch and
/// point at the shortcut that works without the permission.
private func promptForAccessibilityIfNeeded() {
guard !AXIsProcessTrusted() else { return }
let alert = NSAlert()
alert.messageText = "Prompt needs Accessibility access"
alert.informativeText = """
Capturing with Shift-Shift (and showing the panel with right Shift twice) \
only works with Accessibility access.
Until it is granted, use Cmd+Shift+Space to show or hide the panel and \
Cmd+Shift+C to capture, or click the menu bar icon.
"""
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
}
/// A launched-again app must reveal itself. Without this, `open` (or
/// clicking it in Finder/Spotlight) hits the running instance, which does
/// nothing, so the app looks like it never started.
@MainActor
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
PanelController.shared.show()
return true
}
@MainActor
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Stop the poll timer, any queued push, the event tap and the Carbon
// hotkeys so termination cannot be held up by a live callback.
SyncService.shared.stop()
HotkeyManager.shared.stop()
return .terminateNow
}
/// 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(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
}
@MainActor @objc private func hidePanel() {
PanelController.shared.hide()
}
@MainActor
private func writeDiagnostics() {
var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"]
if let window = statusItem?.button?.window {
lines.append("status item frame: \(window.frame)")
if let screen = window.screen ?? NSScreen.main {
lines.append("screen: \(screen.frame) visible: \(screen.visibleFrame)")
lines.append("notch inset: \(screen.safeAreaInsets)")
let underNotch = window.frame.maxX > screen.frame.maxX - screen.safeAreaInsets.right
lines.append("under the notch: \(underNotch)")
}
} else {
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
)
}
private func setupStatusItem() { private func setupStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "Prompt" item.autosaveName = "Prompt"
+16
View File
@@ -34,6 +34,22 @@ final class HotkeyManager {
registerFallbackHotkeys() registerFallbackHotkeys()
} }
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
for ref in hotKeyRefs {
if let ref { UnregisterEventHotKey(ref) }
}
hotKeyRefs.removeAll()
eventTap = nil
runLoopSource = nil
}
// MARK: - Double-Shift event tap // MARK: - Double-Shift event tap
private func startEventTap() { private func startEventTap() {
+2
View File
@@ -31,12 +31,14 @@ final class PanelController: NSObject {
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil) panel.makeKeyAndOrderFront(nil)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
} }
func hide() { func hide() {
panel?.orderOut(nil) panel?.orderOut(nil)
NSApp.deactivate() NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
} }
private func makePanel() -> KeyablePanel { private func makePanel() -> KeyablePanel {
+177 -27
View File
@@ -10,13 +10,16 @@ struct PanelView: View {
@State private var search = "" @State private var search = ""
@State private var showSearch = false @State private var showSearch = false
@State private var showSectionSwitcher = false @State private var showSectionSwitcher = false
@State private var showNewWorkspace = false
@State private var newWorkspaceName = ""
@State private var showSync = false @State private var showSync = false
@State private var joinCode = "" @State private var joinCode = ""
@State private var selection = Set<UUID>() @State private var selection = Set<UUID>()
@State private var lastSelectedID: UUID?
@State private var copiedID: UUID? @State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted() @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? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
@@ -31,12 +34,16 @@ struct PanelView: View {
Divider() Divider()
content content
Divider() Divider()
if !selection.isEmpty {
selectionBar
}
composerField composerField
} }
.frame(minWidth: 300) .frame(minWidth: 300)
.background(shortcutButtons) .background(shortcutButtons)
.overlay(sectionSwitcher) .overlay(sectionSwitcher)
.overlay(syncOverlay) .overlay(syncOverlay)
.overlay(newWorkspaceModal)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer focusedField = .composer
@@ -62,7 +69,7 @@ struct PanelView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Switch section (Cmd+K)") .help("Switch workspace (Cmd+K)")
Spacer() Spacer()
@@ -76,7 +83,8 @@ struct PanelView: View {
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") } Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)") .buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -86,8 +94,13 @@ struct PanelView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text("Shift-tap capture needs Accessibility access.") Text("Shift-tap capture needs Accessibility access.")
.font(.caption) .font(.caption)
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Button("Grant…") { Button("Grant…") {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
@@ -103,7 +116,7 @@ struct PanelView: View {
private var searchField: some View { private var searchField: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary) Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search all sections", text: $search) TextField("Search all workspaces", text: $search)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($focusedField, equals: .search) .focused($focusedField, equals: .search)
.onExitCommand { .onExitCommand {
@@ -217,6 +230,7 @@ struct PanelView: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more")
.contextMenu { contextMenu(for: item) } .contextMenu { contextMenu(for: item) }
} }
@@ -256,7 +270,11 @@ struct PanelView: View {
Button("Copy") { copy(item) } Button("Copy") { copy(item) }
Button("Copy as List") { Button("Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item)) store.copyAsList(ids: effectiveIDs(for: item))
flashCopied(item) flashCopied(item.id)
}
Button("Copy as Lines") {
store.copyAsLines(ids: effectiveIDs(for: item))
flashCopied(item.id)
} }
Divider() Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) } Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) }
@@ -273,7 +291,7 @@ struct PanelView: View {
if selection.count > 1, selection.contains(item.id) { if selection.count > 1, selection.contains(item.id) {
Button("Merge Notes") { Button("Merge Notes") {
store.merge(ids: selection) store.merge(ids: selection)
selection.removeAll() clearSelection()
} }
} }
Menu("Move To") { Menu("Move To") {
@@ -300,12 +318,8 @@ struct PanelView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(store.sections) { section in ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in
Button { HStack(spacing: 6) {
store.activeSectionID = section.id
showSectionSwitcher = false
} label: {
HStack {
Text(section.name) Text(section.name)
.font(.callout) .font(.callout)
Spacer() Spacer()
@@ -317,22 +331,44 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.foregroundStyle(Color.accentColor) .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(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture {
store.activeSectionID = section.id
showSectionSwitcher = false
} }
.buttonStyle(.plain)
} }
Divider() Divider()
Button { Button {
composer = "# "
showSectionSwitcher = false showSectionSwitcher = false
focusedField = .composer newWorkspaceName = ""
showNewWorkspace = true
focusedField = .newWorkspace
} label: { } label: {
HStack { HStack {
Image(systemName: "plus") Image(systemName: "plus")
Text("New Section") Text("New Workspace")
Spacer() Spacer()
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
@@ -450,6 +486,96 @@ 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 {
HStack(spacing: 12) {
Text("\(selection.count) selected")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Copy as List") { copySelection(asList: true) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button("Copy as Lines") { copySelection(asList: false) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.tertiary)
.help("Clear selection (Esc)")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor.opacity(0.12))
}
private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return }
if asList {
store.copyAsList(ids: selection)
} else {
store.copyAsLines(ids: selection)
}
if let id = selection.first { flashCopied(id) }
}
private func clearSelection() {
selection.removeAll()
lastSelectedID = nil
}
// MARK: - Composer // MARK: - Composer
private var composerField: some View { private var composerField: some View {
@@ -458,7 +584,15 @@ struct PanelView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($focusedField, equals: .composer) .focused($focusedField, equals: .composer)
.onSubmit(submit) .onSubmit(submit)
.onExitCommand { PanelController.shared.hide() } .onExitCommand {
// Esc clears a selection first, and only hides the panel
// once there is nothing selected.
if selection.isEmpty {
PanelController.shared.hide()
} else {
clearSelection()
}
}
if !composer.isEmpty { if !composer.isEmpty {
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -500,11 +634,13 @@ struct PanelView: View {
} }
.keyboardShortcut(",", modifiers: .command) .keyboardShortcut(",", modifiers: .command)
Button("") { Button("") {
guard !selection.isEmpty else { return } copySelection(asList: true)
store.copyAsList(ids: selection)
if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) }
} }
.keyboardShortcut("c", modifiers: [.command, .shift]) .keyboardShortcut("c", modifiers: [.command, .shift])
Button("") {
copySelection(asList: false)
}
.keyboardShortcut("c", modifiers: [.command, .option])
} }
.opacity(0) .opacity(0)
.frame(width: 0, height: 0) .frame(width: 0, height: 0)
@@ -514,26 +650,40 @@ struct PanelView: View {
// MARK: - Actions // MARK: - Actions
private func handleClick(_ item: PromptItem) { private func handleClick(_ item: PromptItem) {
if NSApp.currentEvent?.modifierFlags.contains(.command) == true { let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if modifiers.contains(.command) {
if selection.contains(item.id) { if selection.contains(item.id) {
selection.remove(item.id) selection.remove(item.id)
} else { } else {
selection.insert(item.id) selection.insert(item.id)
} }
lastSelectedID = item.id
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id)
return return
} }
copy(item) copy(item)
} }
private func copy(_ item: PromptItem) { private func selectRange(from anchor: UUID, to target: UUID) {
store.copy(item) let ids = displayedItems.map(\.item.id)
flashCopied(item) guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return }
let range = start <= end ? start...end : end...start
selection = Set(ids[range])
lastSelectedID = target
} }
private func flashCopied(_ item: PromptItem) { private func copy(_ item: PromptItem) {
withAnimation { copiedID = item.id } store.copy(item)
flashCopied(item.id)
}
private func flashCopied(_ id: UUID) {
withAnimation { copiedID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
if copiedID == item.id { if copiedID == id {
withAnimation { copiedID = nil } withAnimation { copiedID = nil }
} }
} }
+43 -9
View File
@@ -38,11 +38,17 @@ final class Store: ObservableObject {
private let storeFile: URL private let storeFile: URL
private let imageCache = NSCache<NSString, NSImage>() private let imageCache = NSCache<NSString, NSImage>()
init() { /// `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] let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
directory = appSupport.appendingPathComponent("Prompt", isDirectory: true) self.directory = appSupport.appendingPathComponent("Prompt", isDirectory: true)
imagesDirectory = directory.appendingPathComponent("Images", isDirectory: true) }
storeFile = directory.appendingPathComponent("store.json") imagesDirectory = self.directory.appendingPathComponent("Images", isDirectory: true)
storeFile = self.directory.appendingPathComponent("store.json")
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
load() load()
@@ -130,6 +136,18 @@ final class Store: ObservableObject {
return section 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 // MARK: - Items
@discardableResult @discardableResult
@@ -269,18 +287,34 @@ final class Store: ObservableObject {
} }
func copyAsList(ids: Set<UUID>) { func copyAsList(ids: Set<UUID>) {
let items = sections.flatMap(\.items) let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
writeToPasteboard(list)
}
/// Same selection as `copyAsList`, but joined with plain newlines so it
/// drops into a prompt without numbering.
func copyAsLines(ids: Set<UUID>) {
let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
writeToPasteboard(texts.joined(separator: "\n"))
}
private func selectedTexts(ids: Set<UUID>) -> [String] {
sections.flatMap(\.items)
.filter { ids.contains($0.id) } .filter { ids.contains($0.id) }
.sorted { $0.createdAt < $1.createdAt } .sorted { $0.createdAt < $1.createdAt }
let texts = items.compactMap { item -> String? in .compactMap { item in
if case .text(let text) = item.content { return text } if case .text(let text) = item.content { return text }
return nil return nil
} }
guard !texts.isEmpty else { return } }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
private func writeToPasteboard(_ string: String) {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(list, forType: .string) pasteboard.setString(string, forType: .string)
} }
// MARK: - Images // MARK: - Images
+8
View File
@@ -67,6 +67,14 @@ final class SyncService: ObservableObject {
restartPolling() restartPolling()
} }
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// MARK: - Group membership // MARK: - Group membership
func generateCode() { func generateCode() {