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
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:
@@ -64,3 +64,8 @@ tauri-plugin-log = "2"
|
||||
# the plugin declares android support level "none", which is why the Android client
|
||||
# gets a server-served update path instead (Scribe note 2725).
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
|
||||
# concept of a global shortcut, and its half of this feature is a share-sheet
|
||||
# intent filter instead.
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Core capability for the main ThoughtSync window.",
|
||||
"windows": ["main"],
|
||||
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
|
||||
"windows": ["main", "capture"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Quick capture: a system-wide hotkey that opens a small window to type into.
|
||||
//!
|
||||
//! The point is capture WITHOUT the app. Bringing the whole board forward to write
|
||||
//! one line is the friction this removes, so the shortcut opens a small window of
|
||||
//! its own rather than focusing `main` — and that window closes itself the moment
|
||||
//! the note is saved.
|
||||
//!
|
||||
//! ## Why the shortcut is configurable, and why it starts unset
|
||||
//!
|
||||
//! A global shortcut is the one setting in this app that can collide with software
|
||||
//! it knows nothing about. Whatever default is picked is a key combination taken
|
||||
//! away from something on somebody's machine, silently, at install time. So there
|
||||
//! is no default: the feature is off until someone chooses a combination, and
|
||||
//! choosing one is how it turns on. [`SUGGESTED`] is offered by the UI as a
|
||||
//! starting point, not applied on its behalf.
|
||||
//!
|
||||
//! ## Failure has to be visible
|
||||
//!
|
||||
//! Registering can fail — the combination may already be held by the window
|
||||
//! manager or another app, and on Wayland a compositor may refuse global grabs
|
||||
//! outright. A hotkey that quietly does nothing is worse than one that was never
|
||||
//! offered, because there is nothing to look at and nothing to fix. So the stored
|
||||
//! shortcut and the LIVE registration are reported separately: see
|
||||
//! [`CaptureShortcut`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
use thoughtsync_core::local::{store, Db};
|
||||
|
||||
const SHORTCUT_PREF: &str = "capture_shortcut";
|
||||
|
||||
/// The window the hotkey opens. Also the label the capability file grants to.
|
||||
pub const CAPTURE_WINDOW: &str = "capture";
|
||||
|
||||
/// What the UI offers as a starting point. NOT applied automatically — see above.
|
||||
///
|
||||
/// `CommandOrControl+Shift+N`: the Command/Control split is Tauri's own portable
|
||||
/// spelling, and Shift+N is rare enough to be free on most desktops while still
|
||||
/// meaning "new" to the person pressing it.
|
||||
pub const SUGGESTED: &str = "CommandOrControl+Shift+N";
|
||||
|
||||
/// Emitted to the main window after a capture is saved, so the board reloads.
|
||||
///
|
||||
/// The two windows hold separate copies of the frontend and therefore separate
|
||||
/// Pinia stores; nothing in the capture window's store can reach the board's. The
|
||||
/// note is already in SQLite by the time this fires — this only says "look again".
|
||||
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
|
||||
|
||||
/// The stored shortcut and whether it is actually live.
|
||||
///
|
||||
/// Two fields rather than one because they genuinely disagree: a combination can
|
||||
/// be saved and refuse to register, and the person needs to be told which of those
|
||||
/// they are looking at. `registered: false` with a non-empty `shortcut` is the
|
||||
/// "something else already has this" case.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureShortcut {
|
||||
/// The stored combination, or empty when quick capture is off.
|
||||
pub shortcut: String,
|
||||
/// Whether the OS accepted it. Always false when `shortcut` is empty.
|
||||
pub registered: bool,
|
||||
}
|
||||
|
||||
fn stored(db: &Db) -> Result<String, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(store::pref(&conn, SHORTCUT_PREF)
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Open (or focus) the capture window.
|
||||
///
|
||||
/// Reused rather than recreated: holding one window and showing it is what makes
|
||||
/// the second press feel instant, and it means a half-typed capture survives the
|
||||
/// window being dismissed and reopened.
|
||||
///
|
||||
/// `always_on_top` and `center` because this is summoned over whatever you were
|
||||
/// doing — a capture window that opens behind the app you called it from has
|
||||
/// failed at the only thing it does.
|
||||
fn open_capture_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// `index.html?capture=1` rather than a `/capture` path: the bundled assets are
|
||||
// served as files, so a path with no file behind it is a 404 in the production
|
||||
// build even though it routes fine under the dev server. A query string is
|
||||
// carried through untouched and the router reads it on boot.
|
||||
let built = WebviewWindowBuilder::new(
|
||||
app,
|
||||
CAPTURE_WINDOW,
|
||||
WebviewUrl::App("index.html?capture=1".into()),
|
||||
)
|
||||
.title("Quick capture")
|
||||
.inner_size(520.0, 220.0)
|
||||
.min_inner_size(360.0, 160.0)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.skip_taskbar(true)
|
||||
.build();
|
||||
|
||||
match built {
|
||||
Ok(window) => {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// Never a panic and never fatal: failing to open a capture window must not
|
||||
// take down an app whose board is working fine.
|
||||
Err(e) => log::error!("could not open the capture window: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `shortcut`, replacing whatever was live.
|
||||
///
|
||||
/// Unregisters everything first rather than tracking the previous binding: this
|
||||
/// app owns exactly one global shortcut, so "all of ours" and "the old one" are
|
||||
/// the same set, and keeping a copy of it is one more thing to get out of step.
|
||||
fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> {
|
||||
let manager = app.global_shortcut();
|
||||
let _ = manager.unregister_all();
|
||||
if shortcut.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed: Shortcut = shortcut
|
||||
.parse()
|
||||
.map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?;
|
||||
manager
|
||||
.on_shortcut(parsed, |app, _shortcut, event| {
|
||||
// Pressed only. Without this the window is opened on the press AND on
|
||||
// the release, and the second one lands on the window the first opened.
|
||||
if event.state == ShortcutState::Pressed {
|
||||
open_capture_window(app);
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Something else on this system is already using it ({e})."))
|
||||
}
|
||||
|
||||
/// Restore the stored shortcut at startup.
|
||||
///
|
||||
/// Best-effort by construction: a combination that worked when it was chosen can
|
||||
/// be taken by something installed later, and the app must still open. The failure
|
||||
/// is logged and the UI will show it as not registered when the settings screen is
|
||||
/// next opened.
|
||||
pub fn restore(app: &AppHandle, db: &Db) {
|
||||
let shortcut = match stored(db) {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
log::warn!("could not read the capture shortcut: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match register(app, &shortcut) {
|
||||
Ok(()) => log::info!("quick capture is on: {shortcut}"),
|
||||
Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result<CaptureShortcut, String> {
|
||||
let shortcut = stored(&db)?;
|
||||
// Asked of the manager rather than remembered from startup: the answer can
|
||||
// have changed since, and a settings screen that reports a stale success is
|
||||
// the exact thing this pair of fields exists to prevent.
|
||||
let registered = !shortcut.is_empty()
|
||||
&& shortcut
|
||||
.parse::<Shortcut>()
|
||||
.map(|s| app.global_shortcut().is_registered(s))
|
||||
.unwrap_or(false);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut,
|
||||
registered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a shortcut and make it live, or clear it with an empty string.
|
||||
///
|
||||
/// Registers BEFORE storing, so a combination the system refuses is not written
|
||||
/// down as though it worked — the person would reopen the settings and find it
|
||||
/// listed as their shortcut while nothing happened when they pressed it.
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_set(
|
||||
shortcut: String,
|
||||
app: AppHandle,
|
||||
db: State<'_, Db>,
|
||||
) -> Result<CaptureShortcut, String> {
|
||||
let wanted = shortcut.trim().to_string();
|
||||
register(&app, &wanted)?;
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"quick capture shortcut {}",
|
||||
if wanted.is_empty() {
|
||||
"cleared".to_string()
|
||||
} else {
|
||||
format!("set to {wanted}")
|
||||
}
|
||||
);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut: wanted.clone(),
|
||||
registered: !wanted.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hide the capture window and tell the board to reload.
|
||||
///
|
||||
/// Hidden rather than closed so the next press has a window to show instead of one
|
||||
/// to build. Called after a save and on Escape alike; `saved` is what decides
|
||||
/// whether the board is told to look again.
|
||||
#[tauri::command]
|
||||
pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
if saved {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
// Failure here is cosmetic — the note is saved either way and the board
|
||||
// will show it on its next load — so it is logged, not raised.
|
||||
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
|
||||
log::warn!("could not tell the board about a capture: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//! remains here is the Tauri command surface (`commands`), desktop integration
|
||||
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
|
||||
|
||||
mod capture;
|
||||
mod commands;
|
||||
mod integration;
|
||||
mod update;
|
||||
@@ -80,6 +81,10 @@ pub fn run() {
|
||||
// build without a signing key still starts normally and simply reports that
|
||||
// updates aren't configured.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// The quick-capture hotkey. Registering the combination itself happens in
|
||||
// `setup`, once the store is open and can be asked which one to use — the
|
||||
// plugin only has to exist before then.
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
// Attachment bytes are served to the webview from the local blob store
|
||||
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
|
||||
// the webview is created; the directory it reads from arrives later, in
|
||||
@@ -118,6 +123,9 @@ pub fn run() {
|
||||
// in this directory saying which one the user picked (issue 2183).
|
||||
update::adopt_installer_channel(&db, &dir);
|
||||
sweep_local_trash(&db);
|
||||
// Before the store is handed to the app: `restore` needs to read the
|
||||
// stored shortcut out of it, and after `manage` the Db has moved.
|
||||
capture::restore(app.handle(), &db);
|
||||
app.manage(db);
|
||||
// Attachment bytes live beside the database, filed by content hash, so a
|
||||
// synced image is readable with no network (M10.7d).
|
||||
@@ -176,6 +184,9 @@ pub fn run() {
|
||||
update::update_channel_set,
|
||||
update::update_check,
|
||||
update::update_install,
|
||||
capture::capture_shortcut_get,
|
||||
capture::capture_shortcut_set,
|
||||
capture::capture_done,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the ThoughtSync desktop app");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ const router = createRouter({
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
},
|
||||
{
|
||||
// The quick-capture window (#1899). Its own route because it is its own
|
||||
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
|
||||
// hotkey in a browser tab and nothing to summon it.
|
||||
path: "/capture",
|
||||
name: "capture",
|
||||
component: () => import("../views/CaptureView.vue"),
|
||||
meta: { requiresAuth: true, requiresDesktop: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresDesktop && !isDesktop()) {
|
||||
return { name: "board" };
|
||||
}
|
||||
// The capture window is opened at `index.html?capture=1` rather than at
|
||||
// `/capture`, because the bundled assets are served as files and a path with no
|
||||
// file behind it 404s in the production build — it only routes under the dev
|
||||
// server. A query string survives that, and this is where it becomes a route.
|
||||
if (to.query.capture === "1" && to.name !== "capture") {
|
||||
return { name: "capture" };
|
||||
}
|
||||
// Deliberately NOT applied to /login and /register: bouncing those on desktop
|
||||
// would loop against the requiresAuth guard above the moment a session is
|
||||
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
|
||||
|
||||
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
|
||||
import FilterBar from "../components/FilterBar.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
|
||||
import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge";
|
||||
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
@@ -227,8 +227,21 @@ onMounted(() => {
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
// A note written in the quick-capture window lands in the same SQLite file but a
|
||||
// different Pinia store — this window has no way to know unless it is told.
|
||||
// Registered as a promise because the listener is set up asynchronously, and
|
||||
// unregistered on the way out so a board that has been navigated away from does
|
||||
// not keep reloading itself.
|
||||
let stopCaptureListener: (() => void) | null = null;
|
||||
onMounted(() => {
|
||||
void onCaptured(() => void reload()).then((stop) => {
|
||||
stopCaptureListener = stop;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onBoardKey);
|
||||
stopCaptureListener?.();
|
||||
ui.boardCardFocused = false;
|
||||
});
|
||||
watch([currentView, currentLabel, facetKey], reload);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// The quick-capture window: one field, and two ways out.
|
||||
//
|
||||
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
|
||||
// the person was doing. Everything here is shaped by that: no shell, no nav, no
|
||||
// board — a window that arrives uninvited has to be finishable in one gesture and
|
||||
// leave nothing behind if it isn't.
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { capture } from "../desktop/bridge";
|
||||
|
||||
const body = ref("");
|
||||
const field = ref<HTMLTextAreaElement | null>(null);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
// Focused on arrival, and after a save. The whole feature is "press the keys and
|
||||
// start typing" — a window that needs a click first has not saved anyone a step.
|
||||
await nextTick();
|
||||
field.value?.focus();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const content = body.value.trim();
|
||||
// Nothing typed is not an error, it is a change of mind — the same reading the
|
||||
// board takes of tapping + and walking away.
|
||||
if (!content) {
|
||||
void capture.done(false);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await repo.notes.create({ body: content });
|
||||
body.value = "";
|
||||
await capture.done(true);
|
||||
} catch {
|
||||
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
|
||||
// away the only copy of something the person just wrote, to report a problem
|
||||
// they could otherwise retry their way out of.
|
||||
error.value = "Couldn't save that. Your text is still here — try again.";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
|
||||
// a capture interrupted by something more urgent is still there on the next
|
||||
// press — which is the behaviour that makes it safe to press Escape.
|
||||
void capture.done(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
|
||||
>
|
||||
<textarea
|
||||
ref="field"
|
||||
v-model="body"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||
placeholder="Write it down…"
|
||||
aria-label="New note"
|
||||
@keydown.esc.prevent="dismiss"
|
||||
@keydown.enter.ctrl.prevent="save"
|
||||
@keydown.enter.meta.prevent="save"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- The shortcuts are written down rather than assumed: this window is seen
|
||||
rarely and briefly, and it is the only place they are discoverable. -->
|
||||
<p class="text-xs text-neutral-400">
|
||||
<kbd>Ctrl</kbd>/<kbd>⌘</kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
|
||||
</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user