15 Commits
Author SHA1 Message Date
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
TZGyn c5011d03e7 Let a multi-selection be copied as plain lines as well as a list
Copying several notes only produced a numbered list, which is wrong for
pasting into a prompt that already has structure. The selection now
offers both forms, Shift+click selects a range, and a selection bar
makes it obvious that more than one note is selected. Esc clears the
selection before it hides the panel.
2026-10-09 17:18:04 +08:00
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
Closing the panel only hid it, and launching the app again did nothing
because the running instance ignored the reopen, so it looked like the
app refused to exit. Reopening now shows the panel, and the main menu
makes Cmd+Q and Cmd+W work while the panel has focus. Termination also
tears down the event tap, hotkeys and sync timers instead of leaving
them live.
2026-10-08 10:43:42 +08:00
TZGyn 1bd346ce20 Explain the SSL failure instead of just reporting it
A Postgres URL without an sslmode made lib/pq ask for TLS and die with
"SSL is not enabled on the server", which reads like a server fault when
it is really a connection-string default. The error now says what to
append, and the server retries briefly so a deploy is not marked
unhealthy just because Postgres was still starting.

The sync server URL also defaults to the hosted instance now, so a fresh
install needs no setup.
2026-10-08 10:38:54 +08:00
TZGyn c0dd211ee5 Drop the compose file and systemd unit for the sync server
The server is a single process, so a compose file that only wrapped one
container added a second way to do the same thing. Docker is now the one
documented path, pointing at a Postgres that lives outside the container.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 10:25:21 +08:00
TZGyn cbdf5538aa Add a Dockerfile so the sync server is one command to deploy
Self-hosting meant building the binary by hand and wiring Postgres up
yourself. The image is a static binary on distroless with a built-in
healthcheck flag, and docker-compose brings up Postgres alongside it.

The image itself is unbuilt here: no container runtime is available in
this environment, so only the static Linux build and the healthcheck
flag have been exercised.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 10:23:54 +08:00
TZGyn f3e6fc8fa6 Poll sync cheaply and make the server deployable
Polling re-downloaded the whole store, photos included, every five
seconds. The app now checks a tiny version endpoint and only fetches the
payload when it actually moved, and the server gained request logging,
graceful shutdown, and self-hosting docs so it can run on a VPS instead
of only on localhost.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 10:19:55 +08:00
14 changed files with 1236 additions and 225 deletions
+77 -12
View File
@@ -13,23 +13,53 @@ clicking an item copies it back to the clipboard.
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel, `NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
right click shows the menu (accessibility state, launch at login, open data right click shows the menu (accessibility state, launch at login, open data
folder, quit). Contains the single-instance guard and `LoginItem` folder, quit). Contains the single-instance guard and `LoginItem`
(`SMAppService`). (`SMAppService`). 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
services key equivalents, which is the only way to quit from the keyboard
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
launching an already-running app does something. Supports `--sync-check` and
`--diag` (writes the status item's frame and permission state to
`/tmp/prompt-diag.txt`, then exits).
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton. - `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
Data model: `Section` → `[PromptItem]`, item content is Data model: `Section` → `[PromptItem]`, item content is
`.text(String)` or `.image(String)` (filename). Persists to `.text(String)` or `.image(String)` (filename). Persists to
`~/Library/Application Support/Prompt/store.json`; images are PNG files in `~/Library/Application Support/Prompt/store.json`; images are PNG files in
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add, `Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
toggle done, edit, delete, move, merge, clear done) and clipboard writes toggle done, edit, delete, move, merge, clear done) and clipboard writes
(`copy`, `copyAsList`). Saves on every mutation. (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines;
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, both text modes skip image items). Batch mutations (`delete(ids:)`,
search, add photos, close), item list, composer. Composer `# Name` creates/ `setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
switches sections. Click = copy; Cmd+click = multi-select; right-click = action applies to every selected item and image files are cleaned up once.
context menu (Copy, Copy as List, Done, Edit, Open, Merge, Move To, Delete). Saves on every mutation.
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.
- `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
@@ -40,8 +70,16 @@ clicking an item copies it back to the clipboard.
a global queue); only the store append hops back to main. a global queue); only the store append hops back to main.
- `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group - `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group
code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`,
`sync.version`), pushes the whole store as JSON, and polls every 5s. `sync.version`). The URL defaults to `SyncService.defaultServerURL`
(`https://prompt.tzgyn.dev`); nothing is sent anywhere until a code exists,
because polling only runs when one is set. Pushes the whole store as JSON and
polls every 5s.
- `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). - `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`).
Also ships `Server/Dockerfile` (static binary on distroless) and
`Server/README.md` (self-hosting). Logs one line per request, creates its
schema on startup, shuts down gracefully on SIGTERM so a redeploy cannot cut
off a push, and supports `-healthcheck` (pings local `/healthz`) so the
shell-less image needs no curl for its Docker `HEALTHCHECK`.
## Sync protocol ## Sync protocol
@@ -53,6 +91,7 @@ payload as opaque `JSONB`; it never parses note contents.
| --- | --- | | --- | --- |
| `POST /api/codes` | Generates a unique code and inserts an empty group | | `POST /api/codes` | Generates a unique code and inserts an empty group |
| `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown | | `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown |
| `GET /api/sync/{code}/version` | `{"version":N}` — the cheap poll |
| `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version | | `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version |
| `GET /healthz` | Liveness | | `GET /healthz` | Liveness |
@@ -60,6 +99,9 @@ Client rules:
- Payload = `{sections, activeSectionID, images}`, where `images` maps a - Payload = `{sections, activeSectionID, images}`, where `images` maps a
filename to base64 PNG so photos travel with the notes. filename to base64 PNG so photos travel with the notes.
- Polling is two-step: `GET /version` every 5s (tiny) and the full payload only
when the version moved. Fetching the payload unconditionally would re-download
every base64 photo every 5 seconds.
- Version counter is server-owned; the client stores the last version it saw - Version counter is server-owned; the client stores the last version it saw
in `sync.version` and only applies a remote payload when `remote.version > in `sync.version` and only applies a remote payload when `remote.version >
lastVersion`. lastVersion`.
@@ -135,10 +177,30 @@ brew upgrade --cask prompt
## Gotchas ## Gotchas
- **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
shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C). shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C,
Option+Cmd+C).
This is why Copy has no in-panel shortcut: click-to-copy is the primary This is why Copy has no in-panel shortcut: click-to-copy is the primary
gesture, matching Copper's intent. gesture, matching Copper's intent.
- The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what - The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what
@@ -155,7 +217,10 @@ brew upgrade --cask prompt
the main thread (the hotkey handler already dispatches to a global queue). the main thread (the hotkey handler already dispatches to a global queue).
- The double-Shift tap needs Accessibility; without it the event tap silently - The double-Shift tap needs Accessibility; without it the event tap silently
fails and only the Carbon fallbacks work. The panel banner and the status fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state. menu both surface the permission state, and `AppDelegate` shows a launch
alert when it is missing — otherwise a user who hides the panel is locked out
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
with `Prompt --diag` (`accessibility: false` is the tell).
- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is - `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is
user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS
would otherwise block it with a confusing error. would otherwise block it with a confusing error.
+44 -19
View File
@@ -2,16 +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** — Cmd+click to multi-select, right-click → Copy as List lines, then scrolls. Return sends, Shift+Return inserts a newline.
(or Shift+Cmd+C) for a numbered list ready to paste into a chat. - **Launch at Login** — toggle in the sync panel (Cmd+,) or the menu bar icon's
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. right-click menu, to start Prompt on boot.
- **Search** — Cmd+F searches across all sections. - **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
up/down arrows in the switcher reorder them. (The code and the saved JSON
still call these `sections` so existing data keeps working.)
- **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
@@ -24,18 +38,22 @@ 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 |
| Clear selection | Esc |
| Close panel | Esc | | Close panel | Esc |
Double-Shift gestures require **Accessibility access** (Prompt reads the Double-Shift gestures require **Accessibility access** (Prompt reads the
selection by simulating Cmd+C, then restores your clipboard untouched). The selection by simulating Cmd+C, then restores your clipboard untouched). Until
Cmd+Shift fallbacks work without it. Bare keys like Space and Cmd+C are it is granted, use Cmd+Shift+Space and Cmd+Shift+C, or click the menu bar icon
deliberately not bound inside the panel so they never conflict with typing in — and launching the app again always brings the panel back. Bare keys like
the composer. Space and Cmd+C are deliberately not bound inside the panel so they never
conflict with typing in the composer.
## Install ## Install
@@ -58,17 +76,24 @@ PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable"
go run . # listens on :8080 go run . # listens on :8080
``` ```
Then in the panel: click the sync icon, set the server URL (e.g. Or build the Docker image (`docker build -t prompt-sync Server/`) and point it
`http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on at your own Postgres — see [Server/README.md](Server/README.md) for that and for
the first Mac or paste the code into **Join** on the other. deploying to a host.
Then in the panel: click the sync icon (or Cmd+,), confirm the server URL, and
either **Generate Code** on the first Mac or paste the code into **Join** on the
other.
Notes: Notes:
- Both Macs must be able to reach the server. A LAN address or a Tailscale - Sync defaults to `https://prompt.tzgyn.dev`; change it in the sync panel to
hostname works; `localhost` only works if the server runs on the same Mac. point at your own server. Both Macs must be able to reach it — a LAN address
or a Tailscale hostname works, and `localhost` only works if the server runs
on the same Mac. See [Server/README.md](Server/README.md) to deploy it.
- Joining a group that already has notes replaces what's on that Mac. - Joining a group that already has notes replaces what's on that Mac.
- The app polls every 5 seconds and pushes 1.5s after a change; last write - The app polls every 5 seconds (a tiny version check) and pushes 1.5s after a
wins. Changes that came from the server are not echoed back. change; last write wins, and changes that came from the server are not echoed
back.
- Verify sync from a terminal with `Prompt --sync-check`. - Verify sync from a terminal with `Prompt --sync-check`.
## Build from source ## Build from source
+4
View File
@@ -0,0 +1,4 @@
.git
prompt-sync
prompt-sync-*
*.md
+27
View File
@@ -0,0 +1,27 @@
# Build stage: the server is pure Go (lib/pq is pure Go), so CGO is off and the
# result is a static binary that runs on a shell-less base image.
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /prompt-sync .
# Runtime stage: distroless has no shell, so the binary does its own
# healthcheck (see -healthcheck in main.go). Runs as non-root.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /prompt-sync /prompt-sync
ENV PROMPT_SYNC_ADDR=:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/prompt-sync", "-healthcheck"]
ENTRYPOINT ["/prompt-sync"]
+116
View File
@@ -0,0 +1,116 @@
# Prompt sync server
Small Go service that stores one JSON blob per sync group, keyed by an 8-digit
code (`1234-5678`). There is no authentication: the code is the shared secret,
so anyone who knows it can read and overwrite that group. It is meant to be
self-hosted for personal use, not exposed as a public service.
## Requirements
- Postgres (any recent version; developed against 18)
- A host both Macs can reach — a VPS, a home server, or a Tailscale node
## Run it
```bash
createdb prompt_sync
cd Server
PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable" \
go run . # or: go build -o prompt-sync . && ./prompt-sync
```
| Env var | Default |
| --- | --- |
| `PROMPT_SYNC_ADDR` | `:8080` |
| `PROMPT_SYNC_DATABASE_URL` | `postgres://localhost:5432/prompt_sync?sslmode=disable` |
The schema is created on startup. Every request is logged as
`METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an
in-flight push is not cut off mid-write. If the database is not reachable yet
it retries five times, 2s apart, before giving up.
> **`sslmode` matters.** lib/pq defaults to `sslmode=require`, so a connection
> string copied from a host that runs Postgres *without* TLS (Coolify's
> internal Postgres, a plain `docker run postgres`, Homebrew) fails with
> `pq: SSL is not enabled on the server`. Append `?sslmode=disable` in that
> case — the server prints exactly that hint when it hits this.
## Deploying with Coolify
Use a Dockerfile application pointed at this repo with base directory `Server/`
(so Coolify builds `Server/Dockerfile`), expose port `8080`, and set:
| Variable | Value |
| --- | --- |
| `PROMPT_SYNC_DATABASE_URL` | your Postgres URL **plus `?sslmode=disable`** |
| `PROMPT_SYNC_ADDR` | `:8080` (this is already the default) |
Coolify detects the `HEALTHCHECK` in the Dockerfile and runs
`prompt-sync -healthcheck` with its 5s start period, so the container only goes
live once `/healthz` answers. It also logs a generic warning that a healthcheck
needs `curl` or `wget` — that does not apply here, the binary does its own
check, so there is no reason to add either to the image (it is distroless and
has no shell).
## Run with Docker
The image is a static binary on `distroless` (non-root, no shell), so its
`HEALTHCHECK` calls `prompt-sync -healthcheck`, which pings the local
`/healthz` — no curl needed in the image. That is what `docker ps` reports as
the container health.
```bash
docker build -t prompt-sync Server/
docker run -d --name prompt-sync -p 8080:8080 \
-e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@host:5432/prompt_sync?sslmode=disable" \
prompt-sync
docker logs -f prompt-sync # request log
```
Postgres stays outside the container; the server only needs a URL. If Postgres
runs on the Docker host itself, `host` is `host.docker.internal` on macOS and
Windows, or the host's LAN address on Linux (`--network host` also works).
## Deploy to a host
Build for the host's architecture and ship the image, or build it on the host
itself (a `git clone` plus `docker build` is usually simplest):
```bash
# build on the Mac for a linux/arm64 host, then copy it over
docker build --platform linux/arm64 -t prompt-sync Server/
docker save prompt-sync | gzip > prompt-sync.tar.gz
scp prompt-sync.tar.gz <host>:
ssh <host> 'gunzip -c prompt-sync.tar.gz | docker load'
ssh <host> 'docker run -d --name prompt-sync --restart unless-stopped \
-p 8080:8080 \
-e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@db-host:5432/prompt_sync?sslmode=disable" \
prompt-sync'
```
Put it behind TLS if it is reachable from the internet — the code is the only
secret, and it travels in the URL path. Both plain HTTP and HTTPS work from the
app; an HTTP URL is often fine over a LAN or Tailscale.
## Endpoints
| Method | Path | Behaviour |
| --- | --- | --- |
| `POST` | `/api/codes` | Generates a unique code, inserts an empty group |
| `GET` | `/api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown |
| `GET` | `/api/sync/{code}/version` | `{"version":N}` — cheap poll used by the app |
| `PUT` | `/api/sync/{code}` | Upserts the payload, returns the incremented version |
| `GET` | `/healthz` | Liveness (200) |
Payloads are opaque `JSONB`; the server never parses note contents. Bodies are
capped at 50 MB, which is generous enough for base64 photos.
## Backups
The whole state is one table, so `pg_dump prompt_sync` is a complete backup:
```bash
pg_dump prompt_sync | gzip > prompt_sync-$(date +%F).sql.gz
```
+135 -6
View File
@@ -6,6 +6,7 @@
// //
// POST /api/codes -> {"code":"1234-5678"} (create group) // POST /api/codes -> {"code":"1234-5678"} (create group)
// GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown) // GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown)
// GET /api/sync/{code}/version -> {"version":N} (cheap poll)
// PUT /api/sync/{code} -> {"version":N+1} (upsert payload) // PUT /api/sync/{code} -> {"version":N+1} (upsert payload)
// GET /healthz -> 200 // GET /healthz -> 200
// //
@@ -16,6 +17,7 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -23,7 +25,10 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"regexp" "regexp"
"strings"
"syscall"
"time" "time"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -44,6 +49,13 @@ type Server struct {
} }
func main() { func main() {
// -healthcheck pings the local /healthz and exits non-zero on failure. It
// exists so the container image can run on a shell-less base (distroless),
// where there is no wget or curl for a HEALTHCHECK to use.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(healthcheck())
}
dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL") dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
if dsn == "" { if dsn == "" {
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable" dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable"
@@ -53,14 +65,11 @@ func main() {
addr = ":8080" addr = ":8080"
} }
db, err := sql.Open("postgres", dsn) db, err := openDatabase(dsn)
if err != nil { if err != nil {
log.Fatalf("open database: %v", err) log.Fatalf("connect to database: %v%s", err, hintFor(err))
} }
defer db.Close() defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("connect to database: %v", err)
}
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
log.Fatalf("ensure schema: %v", err) log.Fatalf("ensure schema: %v", err)
} }
@@ -69,13 +78,112 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/codes", s.createCode) mux.HandleFunc("POST /api/codes", s.createCode)
mux.HandleFunc("GET /api/sync/{code}", s.getSync) mux.HandleFunc("GET /api/sync/{code}", s.getSync)
mux.HandleFunc("GET /api/sync/{code}/version", s.getVersion)
mux.HandleFunc("PUT /api/sync/{code}", s.putSync) mux.HandleFunc("PUT /api/sync/{code}", s.putSync)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
server := &http.Server{
Addr: addr,
Handler: withLogging(mux),
ReadHeaderTimeout: 10 * time.Second,
}
// Shut down cleanly so a redeploy (systemd restart, container stop) never
// cuts off an in-flight push and leaves a client thinking it failed.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
<-stop
log.Println("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
log.Printf("prompt-sync listening on %s", addr) log.Printf("prompt-sync listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}
// openDatabase connects, retrying briefly so a deploy is not marked unhealthy
// just because Postgres was still starting up.
func openDatabase(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
var lastErr error
for attempt := 1; attempt <= 5; attempt++ {
if lastErr = db.Ping(); lastErr == nil {
return db, nil
}
if attempt < 5 {
log.Printf("database not ready (attempt %d/5): %v", attempt, lastErr)
time.Sleep(2 * time.Second)
}
}
db.Close()
return nil, lastErr
}
// hintFor turns the most common connection failure into something actionable.
// lib/pq defaults to sslmode=require, so any DSN copied from a host that runs
// Postgres without TLS (Coolify's internal Postgres, docker-compose, Homebrew)
// fails with a bare "SSL is not enabled on the server".
func hintFor(err error) string {
if !strings.Contains(err.Error(), "SSL is not enabled") {
return ""
}
return "\n\nhint: lib/pq defaults to sslmode=require, but that Postgres has TLS off." +
"\n Append ?sslmode=disable to PROMPT_SYNC_DATABASE_URL" +
"\n (e.g. postgres://user:pass@host:5432/prompt_sync?sslmode=disable)."
}
// healthcheck returns 0 when the local /healthz answers 200.
func healthcheck() int {
addr := os.Getenv("PROMPT_SYNC_ADDR")
if addr == "" {
addr = ":8080"
}
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Get("http://" + addr + "/healthz")
if err != nil {
log.Printf("healthcheck: %v", err)
return 1
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Printf("healthcheck: unexpected status %d", response.StatusCode)
return 1
}
return 0
}
// withLogging keeps a one-line trail of method, path, status and duration so a
// self-hosted deployment can be debugged from journalctl or docker logs.
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, recorder.status, time.Since(started).Round(time.Millisecond))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
} }
// createCode generates a unique 8-digit code, formats it as 1234-5678, and // createCode generates a unique 8-digit code, formats it as 1234-5678, and
@@ -136,6 +244,27 @@ func (s *Server) getSync(w http.ResponseWriter, r *http.Request) {
version, updatedAt.UTC().Format(time.RFC3339), data) version, updatedAt.UTC().Format(time.RFC3339), data)
} }
// getVersion is the cheap poll: clients ask for the version every few seconds
// and only download the payload (which can carry base64 images) when it moved.
func (s *Server) getVersion(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if !codePattern.MatchString(code) {
writeError(w, http.StatusBadRequest, "code must look like 1234-5678")
return
}
var version int64
err := s.db.QueryRow(`SELECT version FROM sync_groups WHERE code = $1`, code).Scan(&version)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "unknown code")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
writeJSON(w, http.StatusOK, map[string]int64{"version": version})
}
func (s *Server) putSync(w http.ResponseWriter, r *http.Request) { func (s *Server) putSync(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code") code := r.PathValue("code")
if !codePattern.MatchString(code) { if !codePattern.MatchString(code) {
+110
View File
@@ -27,6 +27,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
// --diag reports where the status item landed. On a notched Mac a
// brand-new item can be placed under the notch and be unclickable,
// which locks the user out once the panel is hidden.
if CommandLine.arguments.contains("--diag") {
setupStatusItem()
installMenuItems()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.writeDiagnostics()
NSApp.terminate(nil)
}
return
}
// Single instance: stale copies keep their hotkeys alive after an // Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance. // update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier let myPid = ProcessInfo.processInfo.processIdentifier
@@ -47,6 +60,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
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).
@@ -56,6 +73,99 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
/// Without Accessibility the Shift-Shift gestures fail silently, so the
/// panel can be hidden with no obvious way back. Say so on launch and
/// point at the shortcut that works without the permission.
private func promptForAccessibilityIfNeeded() {
guard !AXIsProcessTrusted() else { return }
let alert = NSAlert()
alert.messageText = "Prompt needs Accessibility access"
alert.informativeText = """
Capturing with Shift-Shift (and showing the panel with right Shift twice) \
only works with Accessibility access.
Until it is granted, use Cmd+Shift+Space to show or hide the panel and \
Cmd+Shift+C to capture, or click the menu bar icon.
"""
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
}
/// A launched-again app must reveal itself. Without this, `open` (or
/// clicking it in Finder/Spotlight) hits the running instance, which does
/// nothing, so the app looks like it never started.
@MainActor
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
PanelController.shared.show()
return true
}
@MainActor
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Stop the poll timer, any queued push, the event tap and the Carbon
// hotkeys so termination cannot be held up by a live callback.
SyncService.shared.stop()
HotkeyManager.shared.stop()
return .terminateNow
}
/// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace
/// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit
/// routes Cmd+C/V/X/A/Z through, so replacing it breaks text editing in the
/// composer. This only inserts one item and drops SwiftUI's Settings item,
/// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
private func installMenuItems() {
guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return }
if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) {
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")
hide.target = self
let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count
appMenu.insertItem(.separator(), at: quitIndex)
appMenu.insertItem(hide, at: quitIndex + 1)
}
@MainActor @objc private func hidePanel() {
PanelController.shared.hide()
}
@MainActor
private func writeDiagnostics() {
var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"]
if let window = statusItem?.button?.window {
lines.append("status item frame: \(window.frame)")
if let screen = window.screen ?? NSScreen.main {
lines.append("screen: \(screen.frame) visible: \(screen.visibleFrame)")
lines.append("notch inset: \(screen.safeAreaInsets)")
let underNotch = window.frame.maxX > screen.frame.maxX - screen.safeAreaInsets.right
lines.append("under the notch: \(underNotch)")
}
} else {
lines.append("status item has no window (not in the menu bar)")
}
lines.append("preferred position: \(UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") ?? "unset")")
lines.append("main menu:")
for item in NSApp.mainMenu?.items ?? [] {
lines.append(" \(item.submenu?.title ?? item.title)")
for entry in item.submenu?.items ?? [] {
guard !entry.isSeparatorItem else { continue }
let action = entry.action.map(NSStringFromSelector) ?? "-"
lines.append(" \(entry.title) [\(entry.keyEquivalentModifierMask.rawValue)] \(action)")
}
}
try? lines.joined(separator: "\n").write(
to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8
)
}
private func setupStatusItem() { private func setupStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "Prompt" item.autosaveName = "Prompt"
+157
View File
@@ -0,0 +1,157 @@
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)
}
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
View File
@@ -34,6 +34,22 @@ final class HotkeyManager {
registerFallbackHotkeys() registerFallbackHotkeys()
} }
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
for ref in hotKeyRefs {
if let ref { UnregisterEventHotKey(ref) }
}
hotKeyRefs.removeAll()
eventTap = nil
runLoopSource = nil
}
// MARK: - Double-Shift event tap // MARK: - Double-Shift event tap
private func startEventTap() { private func startEventTap() {
+18 -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 }
@@ -31,12 +30,14 @@ final class PanelController: NSObject {
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil) panel.makeKeyAndOrderFront(nil)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil) NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
} }
func hide() { func hide() {
panel?.orderOut(nil) panel?.orderOut(nil)
NSApp.deactivate() NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
} }
private func makePanel() -> KeyablePanel { private func makePanel() -> KeyablePanel {
@@ -54,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")
@@ -69,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)
}
}
+360 -73
View File
@@ -7,16 +7,26 @@ struct PanelView: View {
@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 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 {
@@ -25,24 +35,31 @@ struct PanelView: View {
if !accessibilityGranted { if !accessibilityGranted {
permissionBanner permissionBanner
} }
tabPicker
if showSearch { if showSearch {
searchField searchField
} }
Divider() Divider()
content content
Divider() Divider()
if !selection.isEmpty {
selectionBar
}
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
@@ -62,7 +79,7 @@ struct PanelView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Switch section (Cmd+K)") .help("Switch workspace (Cmd+K)")
Spacer() Spacer()
@@ -75,19 +92,36 @@ struct PanelView: View {
.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("Close (Esc)")
} }
.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")
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text("Shift-tap capture needs Accessibility access.") Text("Shift-tap capture needs Accessibility access.")
.font(.caption) .font(.caption)
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Button("Grant…") { Button("Grant…") {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
@@ -103,13 +137,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") }
@@ -122,21 +156,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 {
@@ -180,8 +220,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())
@@ -195,13 +248,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))
@@ -215,14 +267,15 @@ struct PanelView: View {
.transition(.opacity) .transition(.opacity)
} }
} }
} .contentShape(Rectangle())
.buttonStyle(.plain) .onTapGesture { handleClick(item) }
.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
@@ -252,39 +305,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) flashCopied(item.id)
}
Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") {
store.copyAsLines(ids: ids)
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)
selection.removeAll() 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
} }
} }
} }
@@ -300,12 +367,8 @@ struct PanelView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(store.sections) { section in ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in
Button { HStack(spacing: 6) {
store.activeSectionID = section.id
showSectionSwitcher = false
} label: {
HStack {
Text(section.name) Text(section.name)
.font(.callout) .font(.callout)
Spacer() Spacer()
@@ -317,22 +380,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)
@@ -419,6 +505,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)
@@ -450,23 +542,199 @@ 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
private var selectionBar: some View {
HStack(spacing: 12) {
Text("\(selection.count) selected")
.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)
.foregroundStyle(Color.accentColor)
Button("Copy as Lines") { copySelection(asList: false) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
Button { deleteSelection() } label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Delete selected")
Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.tertiary)
.help("Clear selection (Esc)")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor.opacity(0.12))
}
private func deleteSelection() {
guard !selection.isEmpty else { return }
store.delete(ids: selection)
clearSelection()
}
private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return }
if asList {
store.copyAsList(ids: selection)
} else {
store.copyAsLines(ids: selection)
}
if let id = selection.first { flashCopied(id) }
}
private func clearSelection() {
selection.removeAll()
lastSelectedID = nil
}
// MARK: - Composer // MARK: - Composer
private var composerField: some View { private var composerField: some View {
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 { PanelController.shared.hide() } placeholder: "Add a note or prompt (\(store.activeSection.name))",
onSubmit: submit,
onEscape: {
if selection.isEmpty {
PanelController.shared.hide()
} else {
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() {
@@ -493,18 +761,20 @@ 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() }
} }
.keyboardShortcut(",", modifiers: .command) .keyboardShortcut(",", modifiers: .command)
Button("") { Button("") {
guard !selection.isEmpty else { return } copySelection(asList: true)
store.copyAsList(ids: selection)
if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) }
} }
.keyboardShortcut("c", modifiers: [.command, .shift]) .keyboardShortcut("c", modifiers: [.command, .shift])
Button("") {
copySelection(asList: false)
}
.keyboardShortcut("c", modifiers: [.command, .option])
} }
.opacity(0) .opacity(0)
.frame(width: 0, height: 0) .frame(width: 0, height: 0)
@@ -513,27 +783,44 @@ 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) {
if NSApp.currentEvent?.modifierFlags.contains(.command) == true { let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if selection.contains(item.id) { if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selection.remove(item.id) selectRange(from: anchor, to: item.id)
} else {
selection.insert(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) {
let ids = displayedItems.map(\.item.id)
guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return }
let range = start <= end ? start...end : end...start
selection = Set(ids[range])
lastSelectedID = target
} }
private func copy(_ item: PromptItem) { private func copy(_ item: PromptItem) {
store.copy(item) store.copy(item)
flashCopied(item) flashCopied(item.id)
} }
private func flashCopied(_ item: PromptItem) { private func flashCopied(_ id: UUID) {
withAnimation { copiedID = item.id } withAnimation { copiedID = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
if copiedID == item.id { if copiedID == id {
withAnimation { copiedID = nil } withAnimation { copiedID = nil }
} }
} }
@@ -550,7 +837,7 @@ struct PanelView: View {
focusedField = .search focusedField = .search
} else { } else {
search = "" search = ""
focusedField = .composer composerFocused = true
} }
} }
+78 -13
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) {
@@ -269,18 +318,34 @@ final class Store: ObservableObject {
} }
func copyAsList(ids: Set<UUID>) { func copyAsList(ids: Set<UUID>) {
let items = sections.flatMap(\.items) let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
writeToPasteboard(list)
}
/// Same selection as `copyAsList`, but joined with plain newlines so it
/// drops into a prompt without numbering.
func copyAsLines(ids: Set<UUID>) {
let texts = selectedTexts(ids: ids)
guard !texts.isEmpty else { return }
writeToPasteboard(texts.joined(separator: "\n"))
}
private func selectedTexts(ids: Set<UUID>) -> [String] {
sections.flatMap(\.items)
.filter { ids.contains($0.id) } .filter { ids.contains($0.id) }
.sorted { $0.createdAt < $1.createdAt } .sorted { $0.createdAt < $1.createdAt }
let texts = items.compactMap { item -> String? in .compactMap { item in
if case .text(let text) = item.content { return text } if case .text(let text) = item.content { return text }
return nil return nil
} }
guard !texts.isEmpty else { return } }
let list = texts.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
private func writeToPasteboard(_ string: String) {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(list, forType: .string) pasteboard.setString(string, forType: .string)
} }
// MARK: - Images // MARK: - Images
+31 -8
View File
@@ -39,6 +39,9 @@ final class SyncService: ObservableObject {
private static let urlKey = "sync.serverURL" private static let urlKey = "sync.serverURL"
private static let versionKey = "sync.version" private static let versionKey = "sync.version"
/// Where sync points unless the user overrides it in the sync panel.
static let defaultServerURL = "https://prompt.tzgyn.dev"
private var lastVersion: Int64 { private var lastVersion: Int64 {
get { Int64(defaults.integer(forKey: Self.versionKey)) } get { Int64(defaults.integer(forKey: Self.versionKey)) }
set { defaults.set(newValue, forKey: Self.versionKey) } set { defaults.set(newValue, forKey: Self.versionKey) }
@@ -54,7 +57,7 @@ final class SyncService: ObservableObject {
private init() { private init() {
code = defaults.string(forKey: Self.codeKey) code = defaults.string(forKey: Self.codeKey)
serverURL = defaults.string(forKey: Self.urlKey) ?? "http://localhost:8080" serverURL = defaults.string(forKey: Self.urlKey) ?? Self.defaultServerURL
} }
func start() { func start() {
@@ -64,6 +67,14 @@ final class SyncService: ObservableObject {
restartPolling() restartPolling()
} }
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// MARK: - Group membership // MARK: - Group membership
func generateCode() { func generateCode() {
@@ -181,22 +192,34 @@ final class SyncService: ObservableObject {
} }
private func poll() { private func poll() {
guard !isSyncing, let code, let url = endpoint("/api/sync/\(code)") else { return } guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return }
Task { Task {
do { do {
let (data, response) = try await URLSession.shared.data(from: url) // Cheap poll first: the full payload can be megabytes of
if (response as? HTTPURLResponse)?.statusCode == 404 { // base64 images, so only download it when the version moved.
let (versionData, versionResponse) = try await URLSession.shared.data(from: versionURL)
if (versionResponse as? HTTPURLResponse)?.statusCode == 404 {
lastError = "Sync group no longer exists on the server" lastError = "Sync group no longer exists on the server"
return return
} }
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else {
syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)") syncLog("poll HTTP \((versionResponse as? HTTPURLResponse)?.statusCode ?? -1)")
return return
} }
let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0
guard remoteVersion > lastVersion else {
lastError = 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)
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)
syncLog("poll remote v\(remote.version) local v\(lastVersion)") if let payload = remote.data {
if remote.version > lastVersion, let payload = remote.data {
apply(payload, version: remote.version) apply(payload, version: remote.version)
} else {
lastVersion = remote.version
} }
lastError = nil lastError = nil
} catch { } catch {
+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)
}