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
+94
View File
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import {
SUGGESTED_CAPTURE_SHORTCUT,
capture as captureBridge,
sync as syncBridge,
updates as updateBridge,
type CaptureShortcut,
type Compatibility,
type ProbeResult,
type RevokeOutcome,
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
// this installation on this machine.
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
const shortcutDraft = ref("");
const savingShortcut = ref(false);
const shortcutError = ref("");
async function saveShortcut(value: string) {
savingShortcut.value = true;
shortcutError.value = "";
try {
shortcut.value = await captureBridge.setShortcut(value);
shortcutDraft.value = shortcut.value.shortcut;
} catch (e) {
// The message comes from the core and names the actual reason — "something
// else is already using it" reads very differently from "that is not a
// shortcut this system understands", and both are things you can act on.
shortcutError.value = String((e as { message?: string }).message ?? e);
} finally {
savingShortcut.value = false;
}
}
async function checkUpdates() {
checking.value = true;
updateError.value = "";
@@ -126,6 +154,13 @@ async function refresh() {
// An older build without the update commands — leave the default showing
// rather than blocking the whole Sync screen on it.
}
try {
shortcut.value = await captureBridge.shortcut();
shortcutDraft.value = shortcut.value.shortcut;
} catch {
// Older build without the capture commands. Same reading as the channel
// above — show the default rather than block the screen.
}
try {
status.value = await syncBridge.status();
pending.value = await syncBridge.hasPending();
@@ -452,6 +487,65 @@ onMounted(refresh);
</form>
</template>
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
updates: a hotkey that writes to the local store needs no server. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Quick capture</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
A system-wide shortcut that opens a small window to write a note in, without
bringing this one forward.
</p>
<div class="mt-4 flex items-end gap-3">
<BaseInput
id="capture-shortcut"
v-model="shortcutDraft"
label="Shortcut"
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
class="flex-1"
/>
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
<BaseButton
v-if="shortcut.shortcut"
variant="ghost"
:loading="savingShortcut"
@click="saveShortcut('')"
>
Turn off
</BaseButton>
</div>
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ shortcutError }}
</p>
<!-- Stored and LIVE are reported separately because they can disagree: a
combination another app grabbed first is saved here and does nothing when
pressed, and saying only "your shortcut is X" would be a lie with a
keystroke attached. -->
<p
v-else-if="shortcut.shortcut && !shortcut.registered"
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
>
{{ shortcut.shortcut }} is saved but isn't active something else on this
system is holding it. Try a different combination.
</p>
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Press {{ shortcut.shortcut }} anywhere to capture a note.
</p>
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Off. There's no default on purpose — any combination picked for you is one
taken away from something else on your machine.
<button
type="button"
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
>
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
</button>
</p>
</section>
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
has never touched a server still updates itself. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">