From hotkey to action: the architecture behind QuickPeach.
How the launcher, commands, and notes connect — and where their responsibilities should stay separate.
A small interface can hide several different responsibilities. In QuickPeach, the visible pieces are a hotkey, a query bar, and sticky notes. Explaining the architecture means following what happens between those pieces, not just listing the frameworks used to build them.
The most useful starting point is one complete interaction: opening the launcher, entering a query, selecting an action, and handling its result.
What happens when the launcher opens?
The first part of the architecture is the entry point. The implementation has to connect a keyboard event to the launcher's visibility and focus.
The launcher hotkey is registered at the OS level by the Rust side of the app through Tauri's global-shortcut plugin. It is not a React keyboard listener — by the time the keystroke reaches JavaScript, the launcher window has already been told to show. The platform-specific defaults live in src/shared/lib/hotkeys.ts:
export const DEFAULT_LAUNCHER_HOTKEY = "alt+shift+space"; // macOS — Option + Shift + Space export const WINDOWS_DEFAULT_LAUNCHER_HOTKEY = "ctrl+shift+space"; // Windows
Windows uses Ctrl + Shift + Space instead of Alt + Shift + Space because Windows itself owns Alt + Space as the window-menu shortcut. The hotkey-tab UI uses the same helper to flag that conflict when a user records a custom combo, so we never silently ship a hotkey the OS will swallow.
The launcher is a single Tauri window with its own label. When the OS hotkey fires, Rust creates-or-shows that window; when the user picks a result, the same window calls invoke("hide_window") to dismiss itself. The previous query is cleared on close — you don't see yesterday's "open settings" prefilled today.
That clearing is deliberate, but it has a real cost we'll come back to: it means the launcher doesn't remember what you typed last. If you closed it accidentally, you've lost the query. For a "small place to act on something or keep it nearby" we accepted that trade.
How does a query become an action?
The next part is the path from input to execution. There is a useful conceptual distinction between the text someone enters, the results offered in response, and the action that runs after a selection. The implementation represents those as three separate things.
Commands are described by a flat array of Command objects (src/features/search/launcher/base-commands.ts). Each command carries an id, a name, a description, a category, an icon, free-form keywords, and an action:
{
id: "new-note",
name: "New Note",
description: "Create a blank Markdown note quickly",
icon: "plus",
category: "notes",
action: { type: "create-note", title: "" },
keywords: ["create", "new", "write"],
prefixHint: "@note",
}
When you type, the launcher runs the query through filterCommands in src/features/search/search.ts — a fuzzy match against name + description + keywords. The same module has a sibling filterNotes that ranks notes by fuzzy match against title + content. Both go through rankByFuzzyMatch, so the rank ordering is consistent across the two result types.
When you press Enter, runCommand(command) in src/features/search/CommandList.tsx dispatches on action.type. The dispatch is a flat switch — about ten branches today — and each branch is short:
switch (action.type) { case "app-update-install": await installAppUpdate(); ... case "open-application": await launchApplicationRecord(...); ... case "run-application-command": await runApplicationProviderCommandRecord(...); ... case "refresh-application-catalog": await refreshApplicationCatalog(); ... case "show-main-view": ... case "create-note": openNoteWindow(...); ... case "open-workspace-screen": openWorkspaceScreenWindow(...); ... case "open-utility-tool": openUtilityToolWindow(...); ... case "run-extension-command": await runExtensionCommandRecord(...); ... default: ... }
Two patterns matter here. First, every branch that opens or hides a window uses the same Tauri IPC surface (invoke("hide_window"), openOverlayWindow, etc.) — there is exactly one way to dismiss the launcher, regardless of which command ran. Second, every branch's failure is caught and surfaced as a toast; nothing inside runCommand throws past the call site. If a command can't run, the launcher tells you, but it doesn't break.
The less successful paths matter too. Empty query → launcher shows recent items (pinned notes first, then most-recently-updated, via getRecentNotes(notes, 5)). No results → the row list is empty, the keyboard listener still receives Enter, and the early-return in runCommand handles the no-op without flipping selectedIndex into a negative state (the guard at CommandList.tsx:1118-1123 exists because we hit that exact bug). Failed execution → toast, launcher stays open so you can pick something else.
What is different about notes?
Notes introduce a different concern: content that needs to outlive an individual launcher interaction. An open menu or highlighted search result can be temporary. A note is something the user expects to find again.
Notes are stored as .qpnote files in app_storage_dir()/notes/ — Markdown underneath, plus a thin header for metadata. The Rust side has save_note, delete_note, toggle_pin, export_all_notes, and import_notes as IPC commands. There is no SQL database; the file system is the database.
That decision was deliberate. The comment at the top of note-database.ts says it directly: "Rather than a separate database engine, a table stays a plain markdown GFM table (so sync / export / AI keep working). The Notion look — colored status / priority pills — is layered on at render time." Notes stay portable because the source of truth is the same Markdown that sync, export, and AI all read. The Notion-style "database" view is a render layer, not a storage layer.
Saving is debounced. useNoteAutoSave.ts arms a timer for NOTE_AUTOSAVE_DEBOUNCE_MS; if the editor is empty or unchanged, the hook bails out before saving. The hook also exposes flush() for window-blur, hide, and quit paths — without it, up to a full debounce window of typing would be lost when the window closes. A Rust-side filesystem watcher (features/notes::watcher) keeps the React store in sync with external changes (sync, hand edits, restore).
Version history lives alongside each note at <notes_dir>/versions/<noteId>/<ts>.<ext>. Restoring a note, or the whole library from a backup, uses an atomic directory swap — the staged tree replaces the live tree in a single rename, so a crash mid-restore leaves you with either the old library or the new one, never a partial mix.
Where the pieces connect
The boundary that matters is between three things: the launcher (a transient query bar), the command execution layer (one switch with ten branches), and the notes store (markdown files under a directory).
The launcher and the notes store share infrastructure — the fuzzy matcher, the windowing primitives, the keyboard listener — but they do not share state. The launcher doesn't know what notes exist beyond the result list the search returns; the notes store doesn't know about commands beyond the create-note action that fires when a note command runs. That separation is what lets us add a new command type without touching the note editor, and add a new note field without touching the dispatcher.
The decision I would reconsider: hotkey recording. useHotkeySettings.ts has to call clearHotkeyRecord() from Rust before installing the UI keydown listener, because otherwise the OS-level WM_HOTKEY handler fires first, toggles the launcher, and our listener never runs. The flow has to manually restore the previous hotkey on cancel, timeout, or failure — and we did hit a real bug where the wrong cleanup path could leave the user with no hotkey at all. Today the code guards it with didSucceedRef and a restoreHotkey fallback, but the recording path is the most fragile part of the architecture. If we rebuilt it, I would lift the recording state out of React and into a single Rust-owned controller that owns both the unregister and the re-register lifecycle in one place.