9 Commits
Author SHA1 Message Date
TZGyn d665d493af Stop the sync poll from rebuilding the panel every 5 s
@Published fires even when the assigned value is unchanged, so the poll's
unconditional lastError = nil invalidated PanelView — which observes
SyncService — every 5 seconds, forever, hidden or visible. Over five days
that was ~185 CPU-minutes and ~190 MB, and clicks started to lag.

- Route poll state through setError, which skips equal values.
- Watch SyncService from a tiny SyncDot view instead of the panel body.
- Skip the URL cache on polls and give them timeouts.
- Only reassign the composer placeholder when it changed.
2026-10-22 23:55:32 +08:00
TZGyn 0a6ef314c0 Edit in place and add a global All tab
Editing a prompt opened a separate floating window that ignored the
panel's theme; it is now an in-panel modal that matches. A Workspace/All
segmented tab above the list shows every prompt labeled with its
workspace, so the whole library is browsable without typing a search.
2026-10-17 13:38:03 +08:00
TZGyn a09f7b3196 Make the composer wrap and grow, and surface Launch at Login
Long prompts hit a single-line field before; the composer is now an
NSTextView that wraps and grows up to seven lines, keeps Return as send,
and uses Shift+Return for a newline. Launch at Login already existed in
the menu bar icon's right-click menu, so it is now also a toggle in the
sync panel where it is easier to find.
2026-10-16 22:28:16 +08:00
TZGyn 569e56dbba Add delete to the selection bar and drop the in-header close button
The selection bar covered copying but not the other action people reach
for with a selection. The header also carried a second close button next
to the window's own traffic light, so it was removed in favor of that.
2026-10-09 22:45:16 +08:00
TZGyn 78d56ca79b Make dark mode black instead of the system window grey
The panel is a full-height strip that sits beside your work, where the
system grey reads as a half-lit window next to a dark editor. Dark mode
is now pure black, with card and row colours in one Theme file so both
the panel background and the strip under the titlebar match.
2026-10-09 18:04:08 +08:00
TZGyn 6580a79d40 Select items by clicking the card instead of copying them
Copying was a side effect of a click, which made it impossible to build
a selection with the mouse and surprising when the intent was to pick
something up rather than copy it immediately. A click now selects (or
deselects), and copying is an explicit action from the selection bar or
the right-click menu.
2026-10-09 17:58:38 +08:00
TZGyn c7b4ed32f2 Make the row checkbox select items instead of ticking them off
The checkbox was marking items done, but the intent was to gather items
and act on them: tick several, then right-click to copy, copy as a list,
move, merge or delete them all at once. Done is still available from the
same menu, it is just no longer what the checkbox does.
2026-10-09 17:54:54 +08:00
TZGyn 9f0c0f8113 Make pasting reliable and turn items into checkboxes
Pasting still failed because the panel is a nonactivating panel: it can
be key while the app is not active, and menu key equivalents are not
delivered reliably in that state. Cmd+V is now dispatched to the
responder chain directly, and the app no longer replaces SwiftUI's main
menu — it only adds Hide Panel to it, so the Edit items that AppKit
routes text shortcuts through stay intact.

Rows also gained a checkbox so a workspace works as a checklist without
turning the row into a button, which would swallow the checkbox's clicks.
2026-10-09 17:49:06 +08:00
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
9 changed files with 716 additions and 218 deletions
+59 -11
View File
@@ -13,7 +13,8 @@ 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`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit (`SMAppService`). The sync panel also carries a Launch at Login toggle (the
app's only settings surface). 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 (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 services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so while the panel has focus. `applicationShouldHandleReopen` shows the panel so
@@ -27,18 +28,38 @@ clicking an item copies it back to the clipboard.
`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` for numbered output, `copyAsLines` for plain newlines; (`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:)`,
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, `setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
search, add photos, close), item list, composer. Composer `# Name` creates/ action applies to every selected item and image files are cleaned up once.
switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click Saves on every mutation.
selects a range, and a selection bar offers Copy as List / Copy as Lines; - `Sources/Prompt/Theme.swift` — the panel's appearance-aware colours. Dark
right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit, mode is pure black rather than the system window grey (the panel is a
Open, Merge, Move To, Delete). Esc clears the selection before it hides the full-height strip beside your work, where grey reads as a half-lit window);
panel. Drop target for images/files. light mode keeps the system window colour. `Theme.background` is also handed
to the `NSPanel` so the strip under the transparent titlebar matches, and the
row colour stays slightly lighter than black so cards remain visible.
- `Sources/Prompt/ComposerView.swift` — the chat-style composer
(`NSViewRepresentable` around an `NSTextView`): wraps long prompts, grows to
fit up to seven lines, then scrolls. Return submits, Shift+Return inserts a
newline, Esc forwards to the panel (clear selection, else hide). Height is
measured from the text and pushed into a `height` binding the panel applies
as a frame — a representable cannot resize itself through
`intrinsicContentSize` once SwiftUI offers a larger size.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher,
search, add photos), a segmented Workspace/All tab (All lists every prompt
labeled with its workspace), item list, in-panel edit modal, composer.
There is no in-header close button: the window's own close button hides the
panel. Composer `# Name` creates/
switches sections. Click = select (Shift+click for a range); the selection
bar offers Copy (single item), Copy as List, Copy as Lines and Delete;
right-click = context menu acting on the whole 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` - `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`), and the Cmd+V paste monitor. Editing a note is an in-panel
(separate edit window with a `TextEditor`). modal in `PanelView`, not a window — a separate edit window used to float
loose from the panel and its theme.
- `Sources/Prompt/HotkeyManager.swift` — double-Shift detection via a - `Sources/Prompt/HotkeyManager.swift` — double-Shift detection via a
CGEvent tap on `flagsChanged` (left Shift ×2 = capture, right Shift ×2 = CGEvent tap on `flagsChanged` (left Shift ×2 = capture, right Shift ×2 =
toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space
@@ -156,6 +177,33 @@ brew upgrade --cask prompt
## Gotchas ## Gotchas
- **`@Published` publishes on every assignment, even when the value is
unchanged.** The 5-second sync poll used to assign `lastError = nil`
unconditionally; `PanelView` observes `SyncService`, so the entire panel
(rows, overlays, composer) rebuilt every 5 seconds, forever. Over five days
of uptime that was ~185 CPU-minutes and ~190 MB of footprint, and every
click started to lag. Poll state now goes through `setError`, which no-ops
when equal, and the header's sync dot is its own view so sync publishes
cannot invalidate the list.
- **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items
are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu
silently breaks pasting, copying and select-all in the composer (this shipped
once and had to be reverted). `installMenuItems()` only *augments* the
existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item,
whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
- **Cmd+V is handled by the paste monitor, not the menu.** The panel is a
`.nonactivatingPanel`, so it can be key while the app is not active, and menu
key equivalents are not reliably delivered in that state. The monitor
dispatches `paste:` to the responder chain itself for text, and adds the
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`, and
nested buttons never receive clicks. The row uses `contentShape` +
`onTapGesture` so a click anywhere on the card selects it. Selection is
driven by the card click, the checkbox, Cmd+click (toggle) and Shift+click
(range); the context menu acts on the whole selection via `effectiveIDs(for:)`.
Copying is an explicit action (selection bar or context menu), never a side
effect of a click — that was changed deliberately.
- **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
+23 -9
View File
@@ -2,17 +2,30 @@
A native macOS app for keeping prompts and photos, in the spirit of 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 [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. to your work. Select what you need and send it back to your clipboard.
- **Click to copy** — text notes go back as text, photos go back as images. - **Click to select** — clicking a card selects it (Shift+click for a range),
and the selection bar copies a single item as-is, the whole selection as a
numbered list or as plain newline-separated lines, or deletes it.
- **Text or photo** — copying sends text back as text and a photo back as an
image.
- **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. - **A composer that grows** — long prompts wrap and the input grows up to seven
- **Copy as List / Copy as Lines** — Cmd+click to multi-select (Shift+click for lines, then scrolls. Return sends, Shift+Return inserts a newline.
a range), then copy the selection as a numbered list or as plain - **Launch at Login** — toggle in the sync panel (Cmd+,) or the menu bar icon's
newline-separated text, from the selection bar or the right-click menu. right-click menu, to start Prompt on boot.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
- **Search** — Cmd+F searches across all sections. 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.)
- **An "All" tab** — a global view of every prompt across every workspace,
each labeled with the workspace it lives in.
- **Edit in place** — right-click → Edit opens an in-panel modal on the same
theme, not a separate window.
- **Right-click for actions** — Copy, Copy as List, Copy as Lines, Mark as Done,
Edit, Open, Merge Notes, Move To, Delete — each acting on the whole selection.
- **Search** — Cmd+F searches across all workspaces.
- **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
enter it on the other; no account, no login. The code is the only secret. enter it on the other; no account, no login. The code is the only secret.
- **Local & private** — everything lives in - **Local & private** — everything lives in
@@ -25,9 +38,10 @@ 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 |
| New line in composer | Shift+Return |
| 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 | | Copy selection as plain lines | Option+Cmd+C |
+27 -19
View File
@@ -32,6 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// which locks the user out once the panel is hidden. // which locks the user out once the panel is hidden.
if CommandLine.arguments.contains("--diag") { if CommandLine.arguments.contains("--diag") {
setupStatusItem() setupStatusItem()
installMenuItems()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.writeDiagnostics() self.writeDiagnostics()
NSApp.terminate(nil) NSApp.terminate(nil)
@@ -59,9 +60,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded() promptForAccessibilityIfNeeded()
// Let SwiftUI finish installing its main menu, then augment it.
DispatchQueue.main.async { self.installMenuItems() }
// 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).
Task { @MainActor in Task { @MainActor in
@@ -110,28 +113,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return .terminateNow return .terminateNow
} }
/// Accessory apps have no visible menu bar, but the main menu still /// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace
/// services key equivalents — this is what makes Cmd+Q and Cmd+W work /// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit
/// while the panel has focus. /// routes Cmd+C/V/X/A/Z through, so replacing it breaks text editing in the
private func setupMainMenu() { /// composer. This only inserts one item and drops SwiftUI's Settings item,
let mainMenu = NSMenu() /// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
let appMenuItem = NSMenuItem() private func installMenuItems() {
mainMenu.addItem(appMenuItem) guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return }
let appMenu = NSMenu() if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) {
appMenuItem.submenu = appMenu appMenu.removeItem(at: settings)
}
guard !appMenu.items.contains(where: { $0.action == #selector(hidePanel) }) else { return }
let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w")
hide.target = self hide.target = self
appMenu.addItem(hide) let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count
appMenu.insertItem(.separator(), at: quitIndex)
appMenu.addItem(.separator()) appMenu.insertItem(hide, at: quitIndex + 1)
let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q")
quit.target = self
appMenu.addItem(quit)
NSApp.mainMenu = mainMenu
} }
@MainActor @objc private func hidePanel() { @MainActor @objc private func hidePanel() {
@@ -153,6 +152,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
lines.append("status item has no window (not in the menu bar)") 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("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( try? lines.joined(separator: "\n").write(
to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8 to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8
) )
+159
View File
@@ -0,0 +1,159 @@
import AppKit
import SwiftUI
/// A chat-style composer backed by AppKit: wraps long prompts, grows to fit up
/// to seven lines, then scrolls. Return submits; Shift+Return inserts a
/// newline. A plain SwiftUI `TextField(axis: .vertical)` cannot keep Return as
/// "send", which is why this is not one.
///
/// Height is driven by the caller: the representable measures the text and
/// writes the clamped height into `height`, which the caller applies as a
/// `.frame(height:)`. Driving the frame is what actually resizes a
/// representable — `intrinsicContentSize` is ignored once SwiftUI offers a
/// bigger size.
struct ComposerView: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
@Binding var height: CGFloat
var placeholder: String
var onSubmit: () -> Void
var onEscape: (() -> Void)? = nil
private let maxLines: CGFloat = 7
/// The one-line height, so the composer starts at exactly one line instead
/// of snapping to its real height on the first keystroke.
static var oneLineHeight: CGFloat {
let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
return font.ascender - font.descender + font.leading + 10 // 2 x 5 inset
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.horizontalScrollElasticity = .none
let textView = ComposerTextView()
textView.delegate = context.coordinator
textView.placeholderText = placeholder
textView.isRichText = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 0, height: 5)
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
textView.textColor = .labelColor
textView.drawsBackground = false
// No containerSize: with widthTracksTextView the container follows the
// view's width, which is what makes long lines wrap.
scrollView.documentView = textView
textView.frame = NSRect(origin: .zero, size: scrollView.contentSize)
context.coordinator.watchWindow(of: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? ComposerTextView else { return }
context.coordinator.parent = self
if textView.string != text {
textView.string = text
updateHeight(textView)
}
if textView.placeholderText != placeholder {
textView.placeholderText = placeholder
}
if isFocused, scrollView.window?.firstResponder != textView {
scrollView.window?.makeFirstResponder(textView)
}
}
fileprivate func updateHeight(_ textView: ComposerTextView) {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height
let line = Self.oneLineHeight
let clamped = max(line, min(content, line * maxLines + 1))
if abs(height - clamped) > 0.5 {
// Defer so the binding is not written from inside a layout pass.
DispatchQueue.main.async { self.height = clamped }
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: ComposerView
private var windowObserver: Any?
init(_ parent: ComposerView) { self.parent = parent }
/// Re-assert focus every time the panel becomes key again. The focus
/// binding stays `true` while hidden, so without this a re-shown panel
/// would not refocus the composer.
fileprivate func watchWindow(of textView: ComposerTextView) {
windowObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main
) { [weak self, weak textView] note in
guard let self, let textView,
(note.object as? NSWindow) === textView.window,
self.parent.isFocused else { return }
textView.window?.makeFirstResponder(textView)
}
}
deinit {
if let windowObserver {
NotificationCenter.default.removeObserver(windowObserver)
}
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? ComposerTextView else { return }
if parent.text != textView.string {
parent.text = textView.string
}
parent.updateHeight(textView)
}
// Return sends, Shift+Return inserts a newline. Esc is forwarded so the
// panel can clear its selection or hide, which a TextField used to do
// via onExitCommand.
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
parent.onEscape?()
return true
}
guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
return false
}
parent.onSubmit()
return true
}
}
}
private final class ComposerTextView: NSTextView {
var placeholderText: String = "" {
didSet { needsDisplay = true }
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty, !placeholderText.isEmpty else { return }
let attributes: [NSAttributedString.Key: Any] = [
.font: font as Any,
.foregroundColor: NSColor.placeholderTextColor,
]
let inset = textContainerInset
let origin = NSPoint(x: inset.width + (textContainer?.lineFragmentPadding ?? 0), y: inset.height)
placeholderText.draw(at: origin, withAttributes: attributes)
}
}
+16 -59
View File
@@ -16,7 +16,6 @@ final class PanelController: NSObject {
private var panel: KeyablePanel? private var panel: KeyablePanel?
private var pasteMonitor: Any? private var pasteMonitor: Any?
let editController = EditWindowController()
var isVisible: Bool { panel?.isVisible ?? false } var isVisible: Bool { panel?.isVisible ?? false }
@@ -56,6 +55,10 @@ final class PanelController: NSObject {
panel.isMovableByWindowBackground = true panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = false panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false panel.isReleasedWhenClosed = false
panel.isOpaque = true
// Match the SwiftUI content so the titlebar strip and any resize flash
// do not show the system grey through.
panel.backgroundColor = Theme.background
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared)) panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared))
panel.setFrameAutosaveName("PromptPanel") panel.setFrameAutosaveName("PromptPanel")
@@ -71,77 +74,31 @@ final class PanelController: NSObject {
panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12)) panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12))
} }
// Intercept Cmd+V when the panel is key and the clipboard holds an image // Cmd+V is handled here rather than left to a menu key equivalent. The
// (and no text, so normal text paste into the composer is unaffected). // panel is a nonactivating panel, so it can be key while the app is not
// active, and menu key equivalents are not reliably delivered in that
// state — which is what silently broke pasting. Dispatching paste: to the
// responder chain ourselves removes that dependency.
private func installPasteMonitor() { private func installPasteMonitor() {
guard pasteMonitor == nil else { return } guard pasteMonitor == nil else { return }
pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, let panel = self.panel, event.window == panel else { return event } guard let self, let panel = self.panel, event.window == panel else { return event }
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if modifiers == [.command], event.charactersIgnoringModifiers == "v" { guard modifiers == [.command], event.charactersIgnoringModifiers == "v" else { return event }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
if pasteboard.string(forType: .string) == nil, if pasteboard.string(forType: .string) != nil {
let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
return nil
}
if let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
let image = images.first { let image = images.first {
Task { @MainActor in Store.shared.addImage(image) } Task { @MainActor in Store.shared.addImage(image) }
return nil return nil
} }
}
return event return event
} }
} }
} }
@MainActor
final class EditWindowController: NSObject {
private var window: NSWindow?
func edit(itemID: UUID, text: String) {
let view = EditView(
text: text,
onSave: { [weak self] newText in
Store.shared.updateText(itemID, newText: newText)
self?.window?.close()
},
onCancel: { [weak self] in self?.window?.close() }
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Edit Note"
window.contentView = NSHostingView(rootView: view)
window.isReleasedWhenClosed = false
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
}
private struct EditView: View {
@State var text: String
var onSave: (String) -> Void
var onCancel: () -> Void
var body: some View {
VStack(spacing: 12) {
TextEditor(text: $text)
.font(.body)
.padding(4)
.background(Color.primary.opacity(0.05))
.cornerRadius(6)
HStack {
Spacer()
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Save") { onSave(text) }
.keyboardShortcut(.defaultAction)
.keyboardShortcut("s", modifiers: .command)
}
}
.padding(16)
}
}
+291 -76
View File
@@ -5,19 +5,27 @@ import UniformTypeIdentifiers
struct PanelView: View { struct PanelView: View {
@ObservedObject var store: Store @ObservedObject var store: Store
@ObservedObject private var sync = SyncService.shared @ObservedObject private var sync = SyncService.shared
@State private var composer = "" @State private var composer = ""
@State private var composerFocused = false
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
@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 lastSelectedID: UUID?
@State private var editingItem: PromptItem?
@State private var editText = ""
@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 Tab { case workspace, all }
@State private var tab: Tab = .workspace
private enum Field: Hashable { case search, newWorkspace, editor }
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
@@ -26,6 +34,7 @@ struct PanelView: View {
if !accessibilityGranted { if !accessibilityGranted {
permissionBanner permissionBanner
} }
tabPicker
if showSearch { if showSearch {
searchField searchField
} }
@@ -38,15 +47,18 @@ struct PanelView: View {
composerField composerField
} }
.frame(minWidth: 300) .frame(minWidth: 300)
.background(Theme.backgroundSwiftUI)
.background(shortcutButtons) .background(shortcutButtons)
.overlay(sectionSwitcher) .overlay(sectionSwitcher)
.overlay(syncOverlay) .overlay(syncOverlay)
.overlay(newWorkspaceModal)
.overlay(editModal)
.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 composerFocused = true
accessibilityGranted = AXIsProcessTrusted() accessibilityGranted = AXIsProcessTrusted()
} }
.onAppear { focusedField = .composer } .onAppear { composerFocused = true }
} }
// MARK: - Header // MARK: - Header
@@ -66,27 +78,37 @@ struct PanelView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Switch section (Cmd+K)") .help("Switch workspace (Cmd+K)")
Spacer() Spacer()
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: { Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
Image(systemName: "arrow.triangle.2.circlepath") SyncDot()
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
} }
.buttonStyle(.plain).help("Sync") .buttonStyle(.plain).help("Sync")
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
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") }
.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)
} }
// MARK: - Tabs
/// The global view: every prompt across every workspace, each labeled.
private var tabPicker: some View {
Picker("View", selection: $tab) {
Text(store.activeSection.name).tag(Tab.workspace)
Text("All").tag(Tab.all)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
private var permissionBanner: some View { private var permissionBanner: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
@@ -113,13 +135,13 @@ 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 {
search = "" search = ""
showSearch = false showSearch = false
focusedField = .composer composerFocused = true
} }
if !search.isEmpty { if !search.isEmpty {
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") } Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
@@ -132,21 +154,27 @@ struct PanelView: View {
// MARK: - Content // MARK: - Content
/// Every item across every workspace, each labeled with its workspace.
private var allLabeledItems: [(section: String, item: PromptItem)] {
store.sections.flatMap { section in
section.items.map { (section.name, $0) }
}
}
private var displayedItems: [(section: String, item: PromptItem)] { private var displayedItems: [(section: String, item: PromptItem)] {
if search.isEmpty { if !search.isEmpty {
return store.activeSection.items.map { ("", $0) }
}
let query = search.lowercased() let query = search.lowercased()
return store.sections.flatMap { section in return allLabeledItems.filter { entry in
section.items.compactMap { item in if case .text(let text) = entry.item.content {
switch item.content { return text.lowercased().contains(query)
case .text(let text) where text.lowercased().contains(query): }
return (section.name, item) return false
default:
return nil
} }
} }
if tab == .all {
return allLabeledItems
} }
return store.activeSection.items.map { ("", $0) }
} }
private var content: some View { private var content: some View {
@@ -190,8 +218,21 @@ struct PanelView: View {
.padding(.vertical, 48) .padding(.vertical, 48)
} }
/// 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 { private func row(for item: PromptItem, sectionName: String) -> some View {
Button { handleClick(item) } label: { HStack(alignment: .top, spacing: 8) {
Button {
toggleSelection(item.id)
} label: {
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 15))
.foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55))
}
.buttonStyle(.plain)
.help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete")
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if !sectionName.isEmpty { if !sectionName.isEmpty {
Text(sectionName.uppercased()) Text(sectionName.uppercased())
@@ -205,13 +246,12 @@ struct PanelView: View {
Text(sourceApp) Text(sourceApp)
} }
Text(item.createdAt, style: .time) Text(item.createdAt, style: .time)
if item.done {
Image(systemName: "checkmark")
}
} }
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10) .padding(10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground(item)) .background(rowBackground(item))
@@ -225,15 +265,15 @@ struct PanelView: View {
.transition(.opacity) .transition(.opacity)
} }
} }
} .contentShape(Rectangle())
.buttonStyle(.plain) .onTapGesture { handleClick(item) }
.help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more") .help("Click to select · Shift+click for a range · right-click to copy, delete and more")
.contextMenu { contextMenu(for: item) } .contextMenu { contextMenu(for: item) }
} }
private func rowBackground(_ item: PromptItem) -> Color { private func rowBackground(_ item: PromptItem) -> Color {
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) } if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
return Color.primary.opacity(0.05) return Theme.rowSwiftUI
} }
@ViewBuilder @ViewBuilder
@@ -263,43 +303,53 @@ struct PanelView: View {
} }
private func contextMenu(for item: PromptItem) -> some 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") { copy(item) }
Button("Copy as List") { Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item)) store.copyAsList(ids: ids)
flashCopied(item.id) flashCopied(item.id)
} }
Button("Copy as Lines") { Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") {
store.copyAsLines(ids: effectiveIDs(for: item)) store.copyAsLines(ids: ids)
flashCopied(item.id) 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") {
if case .text(let text) = item.content { store.setDone(!item.done, ids: ids)
}
if case .text(let text) = item.content, !multiple {
Button("Edit…") { Button("Edit…") {
PanelController.shared.editController.edit(itemID: item.id, text: text) editText = text
editingItem = item
focusedField = .editor
} }
} }
if case .image(let filename) = item.content { if case .image(let filename) = item.content, !multiple {
Button("Open") { Button("Open") {
NSWorkspace.shared.open(store.imageURL(for: filename)) NSWorkspace.shared.open(store.imageURL(for: filename))
} }
} }
if selection.count > 1, selection.contains(item.id) { if multiple {
Button("Merge Notes") { Button("Merge Notes") {
store.merge(ids: selection) store.merge(ids: ids)
clearSelection() clearSelection()
} }
} }
Menu("Move To") { Menu("Move To") {
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in 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() Divider()
Button("Delete", role: .destructive) { Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) {
selection.remove(item.id) store.delete(ids: ids)
store.delete(item.id) selection.subtract(ids)
lastSelectedID = nil
} }
} }
} }
@@ -315,12 +365,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()
@@ -332,22 +378,45 @@ 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
tab = .workspace
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)
@@ -434,6 +503,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Divider()
Toggle("Launch at Login", isOn: Binding(
get: { LoginItem.isEnabled },
set: { _ in _ = LoginItem.toggle() }
))
.font(.callout)
if let error = sync.lastError { if let error = sync.lastError {
Text(error) Text(error)
.font(.caption) .font(.caption)
@@ -465,6 +540,109 @@ struct PanelView: View {
} }
} }
// MARK: - Edit
@ViewBuilder
private var editModal: some View {
if let item = editingItem {
ZStack {
Color.black.opacity(0.25)
.onTapGesture { dismissEditor() }
VStack(alignment: .leading, spacing: 12) {
Text("Edit")
.font(.headline)
TextEditor(text: $editText)
.font(.callout)
.scrollContentBackground(.hidden)
.frame(minHeight: 120, maxHeight: 280)
.padding(8)
.background(Theme.rowSwiftUI)
.cornerRadius(8)
.focused($focusedField, equals: .editor)
HStack {
Spacer()
Button("Cancel") { dismissEditor() }
.keyboardShortcut(.cancelAction)
Button("Save") { saveEdit(item) }
.keyboardShortcut("s", modifiers: .command)
.disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(16)
.frame(maxWidth: 320)
.background(Theme.backgroundSwiftUI)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.primary.opacity(0.12), lineWidth: 1)
)
.shadow(radius: 18)
}
.transition(.opacity)
}
}
private func saveEdit(_ item: PromptItem) {
let text = editText.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
store.updateText(item.id, newText: text)
}
dismissEditor()
}
private func dismissEditor() {
editingItem = nil
editText = ""
composerFocused = true
}
// 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 = ""
composerFocused = true
}
// MARK: - Selection // MARK: - Selection
private var selectionBar: some View { private var selectionBar: some View {
@@ -473,6 +651,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
if selection.count == 1, let id = selection.first, let item = store.item(id) {
Button("Copy") { copy(item) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
}
Button("Copy as List") { copySelection(asList: true) } Button("Copy as List") { copySelection(asList: true) }
.font(.caption) .font(.caption)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -481,6 +665,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
Button { deleteSelection() } label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Delete selected")
Button { clearSelection() } label: { Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
} }
@@ -493,6 +683,12 @@ struct PanelView: View {
.background(Color.accentColor.opacity(0.12)) .background(Color.accentColor.opacity(0.12))
} }
private func deleteSelection() {
guard !selection.isEmpty else { return }
store.delete(ids: selection)
clearSelection()
}
private func copySelection(asList: Bool) { private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return } guard !selection.isEmpty else { return }
if asList { if asList {
@@ -511,28 +707,32 @@ struct PanelView: View {
// MARK: - Composer // MARK: - Composer
private var composerField: some View { private var composerField: some View {
HStack(spacing: 8) { HStack(alignment: .bottom, spacing: 8) {
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer) ComposerView(
.textFieldStyle(.plain) text: $composer,
.focused($focusedField, equals: .composer) isFocused: $composerFocused,
.onSubmit(submit) height: $composerHeight,
.onExitCommand { placeholder: "Add a note or prompt (\(store.activeSection.name))",
// Esc clears a selection first, and only hides the panel onSubmit: submit,
// once there is nothing selected. onEscape: {
if selection.isEmpty { if selection.isEmpty {
PanelController.shared.hide() PanelController.shared.hide()
} else { } else {
clearSelection() clearSelection()
} }
} }
)
.frame(maxWidth: .infinity)
.frame(height: composerHeight)
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)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
.help("Add (Return; Shift+Return for a new line)")
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 8)
} }
private func submit() { private func submit() {
@@ -559,7 +759,7 @@ struct PanelView: View {
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
} }
.keyboardShortcut("k", modifiers: .command) .keyboardShortcut("k", modifiers: .command)
Button("") { focusedField = .composer } Button("") { composerFocused = true }
.keyboardShortcut("n", modifiers: .command) .keyboardShortcut("n", modifiers: .command)
Button("") { Button("") {
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
@@ -581,22 +781,25 @@ struct PanelView: View {
// MARK: - Actions // MARK: - Actions
/// A click anywhere on the card selects it (Shift+click selects a range).
/// Copying is then an action on the selection — from the selection bar or
/// the right-click menu — rather than the side effect of a click.
private func handleClick(_ item: PromptItem) { private func handleClick(_ item: PromptItem) {
let modifiers = NSApp.currentEvent?.modifierFlags ?? [] 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
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id { if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id) selectRange(from: anchor, to: item.id)
return return
} }
copy(item) toggleSelection(item.id)
}
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) { private func selectRange(from anchor: UUID, to target: UUID) {
@@ -632,7 +835,7 @@ struct PanelView: View {
focusedField = .search focusedField = .search
} else { } else {
search = "" search = ""
focusedField = .composer composerFocused = true
} }
} }
@@ -676,3 +879,15 @@ struct PanelView: View {
return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
} }
} }
/// Sync status lives in its own view so a `SyncService` publish cannot
/// invalidate the panel's list. Watching it from `PanelView`'s body meant every
/// sync event — including the 5-second poll — re-rendered every row.
private struct SyncDot: View {
@ObservedObject private var sync = SyncService.shared
var body: some View {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
}
}
+57 -8
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
@@ -172,18 +190,49 @@ final class Store: ObservableObject {
} }
func delete(_ id: UUID) { func delete(_ id: UUID) {
delete(ids: [id])
}
/// Deletes every item in `ids` in one pass, cleaning up their image files.
func delete(ids: Set<UUID>) {
guard !ids.isEmpty else { return }
var removedAny = false
for sectionIndex in sections.indices { for sectionIndex in sections.indices {
if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { for item in sections[sectionIndex].items where ids.contains(item.id) {
let item = sections[sectionIndex].items[itemIndex] removedAny = true
if case .image(let filename) = item.content { if case .image(let filename) = item.content {
try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename)) try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename))
imageCache.removeObject(forKey: filename as NSString) imageCache.removeObject(forKey: filename as NSString)
} }
sections[sectionIndex].items.remove(at: itemIndex) }
sections[sectionIndex].items.removeAll { ids.contains($0.id) }
}
if removedAny { save() }
}
func setDone(_ done: Bool, ids: Set<UUID>) {
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() save()
return
} }
/// Moves every item in `ids` into `targetSectionID`, keeping their order.
func move(ids: Set<UUID>, 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) { func move(_ id: UUID, to targetSectionID: UUID) {
+26 -6
View File
@@ -193,13 +193,20 @@ final class SyncService: ObservableObject {
private func poll() { private func poll() {
guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return } guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return }
// A cached /version would keep reporting the version the client already
// has, so polls must always hit the network. The responses are a few
// bytes; letting CFNetwork write them to the disk cache every 5 s only
// adds churn.
var versionRequest = URLRequest(url: versionURL)
versionRequest.cachePolicy = .reloadIgnoringLocalCacheData
versionRequest.timeoutInterval = 15
Task { Task {
do { do {
// Cheap poll first: the full payload can be megabytes of // Cheap poll first: the full payload can be megabytes of
// base64 images, so only download it when the version moved. // base64 images, so only download it when the version moved.
let (versionData, versionResponse) = try await URLSession.shared.data(from: versionURL) let (versionData, versionResponse) = try await URLSession.shared.data(for: versionRequest)
if (versionResponse as? HTTPURLResponse)?.statusCode == 404 { if (versionResponse as? HTTPURLResponse)?.statusCode == 404 {
lastError = "Sync group no longer exists on the server" setError("Sync group no longer exists on the server")
return return
} }
guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else { guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else {
@@ -208,12 +215,15 @@ final class SyncService: ObservableObject {
} }
let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0 let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0
guard remoteVersion > lastVersion else { guard remoteVersion > lastVersion else {
lastError = nil setError(nil)
return return
} }
syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload") syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload")
guard let payloadURL = endpoint("/api/sync/\(code)") else { return } guard let payloadURL = endpoint("/api/sync/\(code)") else { return }
let (data, response) = try await URLSession.shared.data(from: payloadURL) var payloadRequest = URLRequest(url: payloadURL)
payloadRequest.cachePolicy = .reloadIgnoringLocalCacheData
payloadRequest.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: payloadRequest)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
if let payload = remote.data { if let payload = remote.data {
@@ -221,15 +231,25 @@ final class SyncService: ObservableObject {
} else { } else {
lastVersion = remote.version lastVersion = remote.version
} }
lastError = nil setError(nil)
} catch { } catch {
syncLog("poll failed: \(error.localizedDescription)") syncLog("poll failed: \(error.localizedDescription)")
// Polling errors are transient; stay quiet unless nothing ever worked. // Polling errors are transient; stay quiet unless nothing ever worked.
if lastSyncedAt == nil { lastError = "Cannot reach sync server" } if lastSyncedAt == nil { setError("Cannot reach sync server") }
} }
} }
} }
/// `@Published` fires on every assignment, even when the value is
/// unchanged. The 5-second poll would otherwise republish `lastError`
/// forever, and since `PanelView` observes this object, the whole panel
/// (list, rows, overlays) re-rendered every 5 seconds — the app grew to
/// ~190 MB and ~90 ms of layout per poll over five days of uptime.
private func setError(_ message: String?) {
guard lastError != message else { return }
lastError = message
}
private func restartPolling() { private func restartPolling() {
pollTimer?.invalidate() pollTimer?.invalidate()
pollTimer = nil pollTimer = nil
+28
View File
@@ -0,0 +1,28 @@
import AppKit
import SwiftUI
/// Appearance-aware colours for the panel.
///
/// Dark mode is pure black rather than the system window grey: the panel is a
/// full-height strip that sits beside your work, and the grey reads as a
/// half-lit window next to a dark editor or terminal.
enum Theme {
static func isDark(_ appearance: NSAppearance) -> Bool {
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
/// The panel's own background (also handed to the NSPanel so the area under
/// the transparent titlebar matches).
static let background = NSColor(name: nil) { appearance in
isDark(appearance) ? .black : .windowBackgroundColor
}
/// Card background, kept subtle but visible against pure black.
static let row = NSColor(name: nil) { appearance in
isDark(appearance) ? NSColor(white: 1, alpha: 0.07) : NSColor(white: 0, alpha: 0.05)
}
/// Same colours for SwiftUI.
static let backgroundSwiftUI = Color(nsColor: background)
static let rowSwiftUI = Color(nsColor: row)
}