desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s

The other half of #1899. Press the combination anywhere and a 520x220 window
arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The
board never comes forward, which is the whole point — bringing the app up to
write one line is the friction this removes.

## There is no default shortcut, deliberately

A global shortcut is the one setting here that can collide with software this
app knows nothing about. Any default is a key combination taken away from
something on somebody's machine, silently, at install time. So the feature is
OFF until a combination is chosen, and choosing one is how it turns on.
CommandOrControl+Shift+N is offered as a one-click suggestion, never applied
on the user's behalf.

## Stored and live are reported separately

`CaptureShortcut` carries both `shortcut` and `registered`, because they
genuinely disagree: a combination another app grabbed first is saved and does
nothing when pressed, and on Wayland a compositor may refuse global grabs
outright. Saying only "your shortcut is X" would be a lie with a keystroke
attached, so the settings row says "saved but isn't active — something else is
holding it". `capture_shortcut_set` registers BEFORE storing, so a
combination the system refuses is never written down as though it worked.

Registration at startup is best-effort and logged: a shortcut that worked when
it was chosen can be taken by something installed later, and the app must
still open.

## Two windows, one database, no shared store

The capture window runs a second copy of the frontend with its own Pinia
stores, so a note saved there is invisible to the board until it is told. It
is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The
emit failing is cosmetic (the note is already in SQLite) so it is logged, not
raised.

The window is opened at `index.html?capture=1` rather than at `/capture`
because the bundled assets are served as FILES: a path with no file behind it
404s in the production build while routing fine under the dev server. The
router turns the query into the route.

It is hidden rather than closed on the way out, and it keeps its text. A
capture interrupted by something more urgent is still there on the next press,
which is what makes Escape safe to press. A failed save also keeps the window
open holding the text — hiding it would throw away the only copy of something
just written in order to report a problem you could retry your way out of.

## Where the setting lives

Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in
the desktop's Sync screen beside the update channel, not in admin Settings:
that screen is the SERVER's and bounces on desktop anyway, while this is a
property of one installation on one machine. Persisted with the same
`store::set_pref` the update channel uses.

No @tauri-apps/api dependency was added — everything routes through `invoke`
and the `withGlobalTauri` global, as the rest of the bridge does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
2026-09-01 09:02:39 -04:00
co-authored by Claude Opus 5
parent c8318c323a
commit 42e06da576
9 changed files with 511 additions and 3 deletions
+53
View File
@@ -5,6 +5,13 @@
interface TauriGlobal {
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
// windows, each with its own Pinia stores — a note saved in one is invisible to
// the other until something says so, and an event is the only channel between
// them that does not involve polling SQLite.
event?: {
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
};
}
declare global {
@@ -192,3 +199,49 @@ export const updates = {
*/
install: () => invoke<void>("update_install"),
};
// --- Quick capture (#1899) ---------------------------------------------------
/**
* The stored hotkey and whether the OS actually accepted it.
*
* They disagree more often than you would like: a combination can be saved and
* refuse to register because a window manager or another app already holds it,
* and on Wayland a compositor may refuse global grabs entirely. `registered:
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
* to say so — a hotkey that silently does nothing is worse than none, because
* there is nothing to look at and nothing to fix.
*/
export interface CaptureShortcut {
/** The stored combination, or "" when quick capture is off. */
shortcut: string;
registered: boolean;
}
/** Offered as a starting point, never applied on the user's behalf. */
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
/** Fired at the main window after a capture is saved. */
const CAPTURED_EVENT = "thoughtsync://captured";
export const capture = {
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
/** Hide the capture window; `saved` decides whether the board is told to reload. */
done: (saved: boolean) => invoke<void>("capture_done", { saved }),
};
/**
* Run `handler` whenever a note is captured in the other window.
*
* Returns an unlisten function, or a no-op on the web build and on any desktop
* runtime that does not expose the event API — the board simply keeps showing
* what it has until its next load, which is a stale list rather than a broken one.
*/
export async function onCaptured(handler: () => void): Promise<() => void> {
const events = window.__TAURI__?.event;
if (!events) return () => {};
return events.listen(CAPTURED_EVENT, () => handler());
}