12 Commits
Author SHA1 Message Date
TZGyn 8328e8131d Clear moved items from the selection
Move To only cleared the selection when more than one item moved, so a
single moved item stayed selected — invisible — in the target workspace.
Moved items are now always removed from the selection.
2026-10-28 22:12:42 +08:00
TZGyn 6661f41deb Add Copy & Delete to the selection bar
Scissors icon next to the copy actions; a single item copies as-is (image
included), a multi-selection copies as plain lines, then everything selected
is deleted.
2026-10-27 21:36:28 +08:00
TZGyn 9907db373c Add Copy & Delete to the context menu
Copies the item (or the selection, as plain lines) and removes it in one
step — the 'take this out' gesture. No shortcut: Cmd+X belongs to the text
fields, and a panel binding would steal it.
2026-10-27 21:31:30 +08:00
TZGyn 15a2aa92f7 Open on startup by default and block duplicate workspace names
- LoginItem registers on the first bundled launch, so the app opens on
  startup without the user finding the toggle; a user who turns it off is
  never overridden. A never-registered bundle reports SMAppService .notFound
  rather than .notRegistered, so the guard only treats enabled/approval as
  done. --diag now prints the raw status.
- New Workspace refuses a name that already exists (case-insensitive) and
  says so inline; Return cannot bypass it. The composer's '# Name' still
  switches to the existing workspace, which is the quick-capture gesture.
2026-10-26 20:46:02 +08:00
TZGyn e0c47ad010 Scope the selection to the current view
A selection made in one workspace survived switching workspace or tab, so
the selection bar and its batch actions (copy, delete) kept acting on items
that were no longer visible. Switching workspace, switching tab, or closing
search now clears the selection.
2026-10-24 20:20:43 +08:00
TZGyn a75d6fdf41 Fix the composer scroller flash, edit focus, and Shift+Return
- The composer only enables its scroller once the text is taller than the
  seven-line max, so it no longer flashes on every growth step.
- The composer re-asserts first responder only when canStealFocus is true;
  it is off while a modal field or the sync overlay has the keyboard, so
  typing in the edit dialog, search and sync fields no longer bounces back.
- The edit dialog is a ComposerView now: Return saves, Shift+Return inserts
  a newline, Esc cancels, and paste uses the same path as the composer.
- Shift+Return never actually worked: returning false from doCommandBy does
  not fall through to the default newline insertion, so the event died. The
  newline is inserted explicitly and the Shift state comes from the keyDown.
2026-10-23 22:54:04 +08:00
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
8 changed files with 607 additions and 132 deletions
+67 -16
View File
@@ -13,7 +13,10 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
(`SMAppService`): the app auto-registers as a login item on the first
bundled launch (once, tracked by `loginItem.autoRegistered` in UserDefaults)
so it opens on startup by default; the Launch at Login toggle in the sync
panel (the app's only settings surface) is the opt-out. 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
@@ -31,18 +34,41 @@ clicking an item copies it back to the clipboard.
`setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
action applies to every selected item and image files are cleaned up once.
Saves on every mutation.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
search, add photos, close), item list, composer. Composer `# Name` creates/
switches sections. Click = copy; the row checkbox selects, Cmd+click toggles
multi-select, Shift+click selects a range, and a selection bar offers Copy as
List / Copy as Lines; right-click = context menu acting on the selection
(Copy, Copy as List, Copy as Lines, Done, Edit, Open, Merge, Move To,
Delete). Esc clears the selection before it hides the
panel. Drop target for images/files.
- `Sources/Prompt/Theme.swift` — the panel's appearance-aware colours. Dark
mode is pure black rather than the system window grey (the panel is a
full-height strip beside your work, where grey reads as a half-lit window);
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. The vertical
scroller is disabled until the text is taller than the max height, so it
never flashes during growth. The composer re-asserts first responder on
updates only while `canStealFocus` is true; the panel turns it off whenever
a modal field (search, edit, new workspace) or the sync overlay owns the
keyboard — otherwise every render stole focus back mid-edit.
- `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, Copy & Delete
(scissors icon) and Delete;
right-click = context menu acting on the whole selection (Copy, Copy as List,
Copy as Lines, Copy & Delete, Done, Edit, Open, Merge, Move To, Delete). Esc clears the
selection before it hides the panel. Selection is scoped to the current view:
switching workspace, switching tab, or closing search clears it. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
(separate edit window with a `TextEditor`).
`PromptPanel`), and the Cmd+V paste monitor. Editing a note is an in-panel
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
CGEvent tap on `flagsChanged` (left Shift ×2 = capture, right Shift ×2 =
toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space
@@ -160,6 +186,14 @@ brew upgrade --cask prompt
## 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
@@ -172,11 +206,13 @@ brew upgrade --cask prompt
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` for
selecting the item, and nested buttons never receive clicks. The row uses
`contentShape` + `onTapGesture` for click-to-copy instead. Selection is
driven by the checkbox, Cmd+click (toggle) and Shift+click (range); the
context menu acts on the whole selection via `effectiveIDs(for:)`.
- **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,
plain Cmd+C). SwiftUI key equivalents are matched before text input and
would steal characters from the composer TextField. Only Cmd-modified
@@ -194,8 +230,23 @@ brew upgrade --cask prompt
firing. The tap is re-enabled on `tapDisabledByTimeout`.
- `Store` is `@MainActor`; anything reaching it from monitors/callbacks must
go through `Task { @MainActor in }`.
- **The composer steals focus via `updateNSView`, so gate it.** It used to
re-assert first responder on every render, which yanked focus out of the
edit dialog (and search/sync fields) on every keystroke. The gate is
`canStealFocus`; any new modal text field must be included in it.
- **`doCommandBy` has no fall-through once the delegate implements it.**
Returning false for `insertNewline` on Shift+Return killed the event instead
of inserting a newline, so Shift+Return never actually worked. The composer
now inserts it explicitly, and reads the Shift state from the `keyDown`
currently being handled because `NSApp.currentEvent` is not reliably
populated when the command fires.
- `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off
the main thread (the hotkey handler already dispatches to a global queue).
- **`SMAppService.mainApp.status` is `.notFound` for a bundle BTM has never
seen**, not `.notRegistered`, and `register()` still works from that state.
Only treat `.enabled` and `.requiresApproval` as "already done"; checking for
`.notRegistered` silently skips the registration. `Prompt --diag` prints the
raw status.
- 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
menu both surface the permission state, and `AppDelegate` shows a launch
+18 -12
View File
@@ -2,25 +2,30 @@
A native macOS app for keeping prompts and photos, in the spirit of
[Copper](https://shadcn.com/copper) by shadcn: a floating panel that sits next
to your work. Click anything to copy it back to your clipboard.
to your work. Select what you need and send it back to your clipboard.
- **Checkbox to select** — every item has a checkbox; tick several and act on
them at once (copy, delete, move, merge). Clicking the row itself still
copies that item.
- **Click to copy** — text notes go back as text, photos go back as images.
- **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**.
- **Photos** — paste (Cmd+V), drag & drop, or add via the photo button.
- **A composer that grows** — long prompts wrap and the input grows up to seven
lines, then scrolls. Return sends, Shift+Return inserts a newline.
- **Launch at Login** — toggle in the sync panel (Cmd+,) or the menu bar icon's
right-click menu, to start Prompt on boot.
- **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
up/down arrows in the switcher reorder them. (The code and the saved JSON
still call these `sections` so existing data keeps working.)
- **Copy as List / Copy as Lines** — select with the checkboxes (or Cmd+click,
or Shift+click for a range), then copy the selection as a numbered list or as
plain newline-separated text, from the selection bar, the right-click menu, or
Shift+Cmd+C / Option+Cmd+C. The same menu deletes, moves or merges the
selection.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections.
- **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
enter it on the other; no account, no login. The code is the only secret.
- **Local & private** — everything lives in
@@ -36,6 +41,7 @@ to your work. Click anything to copy it back to your clipboard.
| Switch/create workspace | Cmd+K |
| Search | Cmd+F |
| Focus composer | Cmd+N |
| New line in composer | Shift+Return |
| Sync settings | Cmd+, |
| Copy selection as numbered list | Shift+Cmd+C |
| Copy selection as plain lines | Option+Cmd+C |
+27
View File
@@ -61,6 +61,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
setupStatusItem()
promptForAccessibilityIfNeeded()
LoginItem.autoRegisterOnce()
// Let SwiftUI finish installing its main menu, then augment it.
DispatchQueue.main.async { self.installMenuItems() }
@@ -140,6 +141,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor
private func writeDiagnostics() {
var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"]
lines.append("login item status: \(SMAppService.mainApp.status.rawValue)")
if let window = statusItem?.button?.window {
lines.append("status item frame: \(window.frame)")
if let screen = window.screen ?? NSScreen.main {
@@ -266,6 +268,31 @@ enum LoginItem {
SMAppService.mainApp.status == .enabled
}
/// Registers the app as a login item on first launch so Prompt opens on
/// startup without the user hunting for the toggle. Runs once (tracked in
/// UserDefaults) so a user who disables it is never overridden, and skips
/// `swift run`, where there is no bundle to register.
static func autoRegisterOnce() {
let key = "loginItem.autoRegistered"
guard Bundle.main.bundleIdentifier != nil,
!UserDefaults.standard.bool(forKey: key) else { return }
// A never-registered bundle reports `.notFound` on current macOS
// (BTM has no record), not `.notRegistered`, so only skip when there
// is nothing left to do.
switch SMAppService.mainApp.status {
case .enabled, .requiresApproval:
UserDefaults.standard.set(true, forKey: key)
default:
do {
try SMAppService.mainApp.register()
UserDefaults.standard.set(true, forKey: key)
NSLog("Prompt: registered as a login item (%d)", SMAppService.mainApp.status.rawValue)
} catch {
NSLog("Prompt: login item registration failed: %@", error.localizedDescription)
}
}
}
static func toggle() -> Bool {
let service = SMAppService.mainApp
do {
+184
View File
@@ -0,0 +1,184 @@
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
/// When false, the composer never makes itself first responder. The panel
/// turns this off while a modal field (search, edit, new workspace) or the
/// sync overlay is up — otherwise every render stole focus back here.
var canStealFocus: Bool = true
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
// The scroller starts disabled and is only switched on once the text
// is taller than the max height. With it always on, every growth step
// briefly overflows and the scroller flashes for one frame.
scrollView.hasVerticalScroller = false
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, canStealFocus, 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 maxHeight = line * maxLines + 1
textView.enclosingScrollView?.hasVerticalScroller = content > maxHeight
let clamped = max(line, min(content, maxHeight))
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, self.parent.canStealFocus 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 }
// NSApp.currentEvent is not reliably set when the command fires,
// so the Shift state comes from the keyDown currently being handled.
// And returning false here does NOT fall through to the default
// newline insertion once a delegate implements doCommandBy — the
// event just dies — so insert the newline explicitly.
if (textView as? ComposerTextView)?.keyDownModifiers.contains(.shift) == true {
textView.insertNewline(nil)
return true
}
parent.onSubmit()
return true
}
}
}
private final class ComposerTextView: NSTextView {
var placeholderText: String = "" {
didSet { needsDisplay = true }
}
/// Modifier flags of the keyDown currently being handled. `doCommandBy`
/// runs inside that call tree and cannot rely on `NSApp.currentEvent`.
var keyDownModifiers: NSEvent.ModifierFlags = []
override func keyDown(with event: NSEvent) {
keyDownModifiers = event.modifierFlags
super.keyDown(with: event)
keyDownModifiers = []
}
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)
}
}
+4 -53
View File
@@ -16,7 +16,6 @@ final class PanelController: NSObject {
private var panel: KeyablePanel?
private var pasteMonitor: Any?
let editController = EditWindowController()
var isVisible: Bool { panel?.isVisible ?? false }
@@ -56,6 +55,10 @@ final class PanelController: NSObject {
panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = 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.contentView = NSHostingView(rootView: PanelView(store: Store.shared))
panel.setFrameAutosaveName("PromptPanel")
@@ -98,56 +101,4 @@ final class PanelController: NSObject {
}
}
@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)
}
}
+252 -44
View File
@@ -5,8 +5,9 @@ import UniformTypeIdentifiers
struct PanelView: View {
@ObservedObject var store: Store
@ObservedObject private var sync = SyncService.shared
@State private var composer = ""
@State private var composerFocused = false
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
@State private var search = ""
@State private var showSearch = false
@State private var showSectionSwitcher = false
@@ -16,10 +17,17 @@ struct PanelView: View {
@State private var joinCode = ""
@State private var selection = Set<UUID>()
@State private var lastSelectedID: UUID?
@State private var editingItem: PromptItem?
@State private var editText = ""
@State private var editFocused = false
@State private var editHeight: CGFloat = ComposerView.oneLineHeight
@State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted()
private enum Field: Hashable { case composer, search, newWorkspace }
private enum Tab { case workspace, all }
@State private var tab: Tab = .workspace
private enum Field: Hashable { case search, newWorkspace }
@FocusState private var focusedField: Field?
var body: some View {
@@ -28,6 +36,7 @@ struct PanelView: View {
if !accessibilityGranted {
permissionBanner
}
tabPicker
if showSearch {
searchField
}
@@ -40,16 +49,23 @@ struct PanelView: View {
composerField
}
.frame(minWidth: 300)
.background(Theme.backgroundSwiftUI)
.background(shortcutButtons)
.overlay(sectionSwitcher)
.overlay(syncOverlay)
.overlay(newWorkspaceModal)
.overlay(editModal)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer
composerFocused = true
accessibilityGranted = AXIsProcessTrusted()
}
.onAppear { focusedField = .composer }
.onAppear { composerFocused = true }
// Selection is scoped to the current view: switching workspace, tab,
// or leaving search clears it. Otherwise a selection made in one
// workspace kept acting (copy, delete) on items you can no longer see.
.onChange(of: store.activeSectionID) { clearSelection() }
.onChange(of: tab) { clearSelection() }
}
// MARK: - Header
@@ -74,22 +90,32 @@ struct PanelView: View {
Spacer()
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
SyncDot()
}
.buttonStyle(.plain).help("Sync")
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.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(.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 {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
@@ -122,7 +148,9 @@ struct PanelView: View {
.onExitCommand {
search = ""
showSearch = false
focusedField = .composer
focusedField = nil
clearSelection()
composerFocused = true
}
if !search.isEmpty {
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
@@ -135,21 +163,27 @@ struct PanelView: View {
// 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)] {
if search.isEmpty {
return store.activeSection.items.map { ("", $0) }
}
if !search.isEmpty {
let query = search.lowercased()
return store.sections.flatMap { section in
section.items.compactMap { item in
switch item.content {
case .text(let text) where text.lowercased().contains(query):
return (section.name, item)
default:
return nil
return allLabeledItems.filter { entry in
if case .text(let text) = entry.item.content {
return text.lowercased().contains(query)
}
return false
}
}
if tab == .all {
return allLabeledItems
}
return store.activeSection.items.map { ("", $0) }
}
private var content: some View {
@@ -242,13 +276,13 @@ struct PanelView: View {
}
.contentShape(Rectangle())
.onTapGesture { handleClick(item) }
.help("Click to copy · checkbox to tick off · 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) }
}
private func rowBackground(_ item: PromptItem) -> Color {
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
return Color.primary.opacity(0.05)
return Theme.rowSwiftUI
}
@ViewBuilder
@@ -290,13 +324,19 @@ struct PanelView: View {
store.copyAsLines(ids: ids)
flashCopied(item.id)
}
Button(multiple ? "Copy & Delete \(ids.count) Items" : "Copy & Delete") {
copyAndDelete(item)
}
Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") {
store.setDone(!item.done, ids: ids)
}
if case .text(let text) = item.content, !multiple {
Button("Edit…") {
PanelController.shared.editController.edit(itemID: item.id, text: text)
editText = text
editHeight = ComposerView.oneLineHeight
editingItem = item
editFocused = true
}
}
if case .image(let filename) = item.content, !multiple {
@@ -314,7 +354,11 @@ struct PanelView: View {
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
Button(section.name) {
store.move(ids: ids, to: section.id)
if multiple { clearSelection() }
// Moved items leave the current view, so they must
// leave the selection too — otherwise they stay
// selected, invisible, in the target workspace.
selection.subtract(ids)
lastSelectedID = nil
}
}
}
@@ -376,6 +420,7 @@ struct PanelView: View {
.contentShape(Rectangle())
.onTapGesture {
store.activeSectionID = section.id
tab = .workspace
showSectionSwitcher = false
}
}
@@ -475,6 +520,12 @@ struct PanelView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
Toggle("Launch at Login", isOn: Binding(
get: { LoginItem.isEnabled },
set: { _ in _ = LoginItem.toggle() }
))
.font(.callout)
if let error = sync.lastError {
Text(error)
.font(.caption)
@@ -506,8 +557,85 @@ struct PanelView: View {
}
}
// MARK: - Edit
/// The editor is a ComposerView too, so it behaves exactly like the main
/// input: Return saves, Shift+Return inserts a newline, Esc cancels, and
/// pasting goes through the same working path.
@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)
ComposerView(
text: $editText,
isFocused: $editFocused,
height: $editHeight,
placeholder: "",
onSubmit: { saveEdit(item) },
onEscape: { dismissEditor() }
)
.frame(height: editHeight)
.padding(8)
.background(Theme.rowSwiftUI)
.cornerRadius(8)
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 = ""
editFocused = false
composerFocused = true
}
// MARK: - New workspace
private var trimmedWorkspaceName: String {
newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Case-insensitive, like the store's dedupe. The modal blocks creation
/// with a hint instead of silently switching to the existing workspace.
private var workspaceNameExists: Bool {
let name = trimmedWorkspaceName
return !name.isEmpty && store.sections.contains {
$0.name.caseInsensitiveCompare(name) == .orderedSame
}
}
@ViewBuilder
private var newWorkspaceModal: some View {
if showNewWorkspace {
@@ -522,12 +650,17 @@ struct PanelView: View {
.focused($focusedField, equals: .newWorkspace)
.onSubmit(createWorkspace)
.onExitCommand { dismissNewWorkspace() }
if workspaceNameExists {
Text("A workspace named “\(trimmedWorkspaceName)” already exists.")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel") { dismissNewWorkspace() }
Button("Create") { createWorkspace() }
.keyboardShortcut(.defaultAction)
.disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty)
.disabled(trimmedWorkspaceName.isEmpty || workspaceNameExists)
}
}
.padding(16)
@@ -541,8 +674,8 @@ struct PanelView: View {
}
private func createWorkspace() {
let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
let name = trimmedWorkspaceName
guard !name.isEmpty, !workspaceNameExists else { return }
store.createSection(named: name)
dismissNewWorkspace()
}
@@ -550,7 +683,8 @@ struct PanelView: View {
private func dismissNewWorkspace() {
showNewWorkspace = false
newWorkspaceName = ""
focusedField = .composer
focusedField = nil
composerFocused = true
}
// MARK: - Selection
@@ -561,6 +695,12 @@ struct PanelView: View {
.font(.caption)
.foregroundStyle(.secondary)
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) }
.font(.caption)
.buttonStyle(.plain)
@@ -569,6 +709,18 @@ struct PanelView: View {
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button { copyAndDeleteSelection() } label: {
Image(systemName: "scissors")
}
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
.help("Copy & Delete")
Button { deleteSelection() } label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Delete selected")
Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill")
}
@@ -581,6 +733,26 @@ struct PanelView: View {
.background(Color.accentColor.opacity(0.12))
}
private func deleteSelection() {
guard !selection.isEmpty else { return }
store.delete(ids: selection)
clearSelection()
}
/// Same as the context menu's Copy & Delete, for the selection bar. A
/// single image still copies as an image; a multi-selection copies as
/// plain lines, same as Copy as Lines.
private func copyAndDeleteSelection() {
guard !selection.isEmpty else { return }
if selection.count == 1, let id = selection.first, let item = store.item(id) {
store.copy(item)
} else {
store.copyAsLines(ids: selection)
}
store.delete(ids: selection)
clearSelection()
}
private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return }
if asList {
@@ -599,28 +771,35 @@ struct PanelView: View {
// MARK: - Composer
private var composerField: some View {
HStack(spacing: 8) {
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer)
.textFieldStyle(.plain)
.focused($focusedField, equals: .composer)
.onSubmit(submit)
.onExitCommand {
// Esc clears a selection first, and only hides the panel
// once there is nothing selected.
HStack(alignment: .bottom, spacing: 8) {
ComposerView(
text: $composer,
isFocused: $composerFocused,
height: $composerHeight,
placeholder: "Add a note or prompt (\(store.activeSection.name))",
// A modal field (search, edit, new workspace) or the sync
// overlay owns the keyboard; don't steal focus back mid-edit.
canStealFocus: focusedField == nil && !showSync && editingItem == nil,
onSubmit: submit,
onEscape: {
if selection.isEmpty {
PanelController.shared.hide()
} else {
clearSelection()
}
}
)
.frame(maxWidth: .infinity)
.frame(height: composerHeight)
if !composer.isEmpty {
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
.help("Add (Return; Shift+Return for a new line)")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.padding(.vertical, 8)
}
private func submit() {
@@ -647,7 +826,7 @@ struct PanelView: View {
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
}
.keyboardShortcut("k", modifiers: .command)
Button("") { focusedField = .composer }
Button("") { composerFocused = true }
.keyboardShortcut("n", modifiers: .command)
Button("") {
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
@@ -669,17 +848,16 @@ struct PanelView: View {
// 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) {
let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if modifiers.contains(.command) {
toggleSelection(item.id)
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id)
return
}
copy(item)
toggleSelection(item.id)
}
private func toggleSelection(_ id: UUID) {
@@ -704,6 +882,22 @@ struct PanelView: View {
flashCopied(item.id)
}
/// One gesture for "take this out": copy, then delete. A multi-selection
/// is copied as plain lines, same as Copy as Lines. No shortcut — Cmd+X
/// belongs to the composer/editor text fields, and a panel binding would
/// steal Cut from them.
private func copyAndDelete(_ item: PromptItem) {
let ids = effectiveIDs(for: item)
if ids.count > 1 {
store.copyAsLines(ids: ids)
} else {
store.copy(item)
}
store.delete(ids: ids)
selection.subtract(ids)
lastSelectedID = nil
}
private func flashCopied(_ id: UUID) {
withAnimation { copiedID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
@@ -724,7 +918,9 @@ struct PanelView: View {
focusedField = .search
} else {
search = ""
focusedField = .composer
focusedField = nil
clearSelection()
composerFocused = true
}
}
@@ -768,3 +964,15 @@ struct PanelView: View {
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)
}
}
+26 -6
View File
@@ -193,13 +193,20 @@ final class SyncService: ObservableObject {
private func poll() {
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 {
do {
// Cheap poll first: the full payload can be megabytes of
// 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 {
lastError = "Sync group no longer exists on the server"
setError("Sync group no longer exists on the server")
return
}
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
guard remoteVersion > lastVersion else {
lastError = nil
setError(nil)
return
}
syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload")
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 }
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
if let payload = remote.data {
@@ -221,15 +231,25 @@ final class SyncService: ObservableObject {
} else {
lastVersion = remote.version
}
lastError = nil
setError(nil)
} catch {
syncLog("poll failed: \(error.localizedDescription)")
// 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() {
pollTimer?.invalidate()
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)
}