From ebee32b8b2b76e33394b0f6e8b6293d6eb269598 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:33:26 +0000 Subject: [PATCH 01/11] feat: add tauri-plugin-shortcut with centralized shortcut registry - Create plugins/shortcut/ with types, registry, and get_all_shortcuts command - Wire plugin into app (Cargo.toml, lib.rs, capabilities, package.json) - Create useGlobalShortcuts hook centralizing ~13 global shortcuts - Migrate shortcuts from body/index.tsx, chat.ts, settings.ts to hook - Mount useGlobalShortcuts in main layout Co-Authored-By: yujonglee --- Cargo.lock | 13 + Cargo.toml | 1 + apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 1 + apps/desktop/src-tauri/src/lib.rs | 1 + .../src/components/main/body/index.tsx | 230 +------------ apps/desktop/src/contexts/shell/chat.ts | 13 - apps/desktop/src/contexts/shell/settings.ts | 13 - apps/desktop/src/hooks/useGlobalShortcuts.ts | 162 +++++++++ apps/desktop/src/routes/app/main/_layout.tsx | 7 + plugins/shortcut/.gitignore | 17 + plugins/shortcut/Cargo.toml | 20 ++ plugins/shortcut/build.rs | 5 + plugins/shortcut/js/bindings.gen.ts | 90 +++++ plugins/shortcut/js/index.ts | 1 + plugins/shortcut/package.json | 11 + .../commands/get_all_shortcuts.toml | 13 + .../permissions/autogenerated/reference.md | 43 +++ plugins/shortcut/permissions/default.toml | 3 + .../shortcut/permissions/schemas/schema.json | 318 ++++++++++++++++++ plugins/shortcut/src/commands.rs | 8 + plugins/shortcut/src/lib.rs | 45 +++ plugins/shortcut/src/registry.rs | 251 ++++++++++++++ plugins/shortcut/src/types.rs | 26 ++ plugins/shortcut/tsconfig.json | 5 + pnpm-lock.yaml | 9 + 27 files changed, 1053 insertions(+), 255 deletions(-) create mode 100644 apps/desktop/src/hooks/useGlobalShortcuts.ts create mode 100644 plugins/shortcut/.gitignore create mode 100644 plugins/shortcut/Cargo.toml create mode 100644 plugins/shortcut/build.rs create mode 100644 plugins/shortcut/js/bindings.gen.ts create mode 100644 plugins/shortcut/js/index.ts create mode 100644 plugins/shortcut/package.json create mode 100644 plugins/shortcut/permissions/autogenerated/commands/get_all_shortcuts.toml create mode 100644 plugins/shortcut/permissions/autogenerated/reference.md create mode 100644 plugins/shortcut/permissions/default.toml create mode 100644 plugins/shortcut/permissions/schemas/schema.json create mode 100644 plugins/shortcut/src/commands.rs create mode 100644 plugins/shortcut/src/lib.rs create mode 100644 plugins/shortcut/src/registry.rs create mode 100644 plugins/shortcut/src/types.rs create mode 100644 plugins/shortcut/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 53b74c581a..44c7186f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4748,6 +4748,7 @@ dependencies = [ "tauri-plugin-settings", "tauri-plugin-sfx", "tauri-plugin-shell", + "tauri-plugin-shortcut", "tauri-plugin-sidecar2", "tauri-plugin-single-instance", "tauri-plugin-store", @@ -19403,6 +19404,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-shortcut" +version = "0.1.0" +dependencies = [ + "serde", + "specta", + "specta-typescript", + "tauri", + "tauri-plugin", + "tauri-specta", +] + [[package]] name = "tauri-plugin-sidecar2" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7f30792e2d..5d2a881268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,6 +185,7 @@ tauri-plugin-permissions = { path = "plugins/permissions" } tauri-plugin-relay = { path = "plugins/relay" } tauri-plugin-screen = { path = "plugins/screen" } tauri-plugin-settings = { path = "plugins/settings" } +tauri-plugin-shortcut = { path = "plugins/shortcut" } tauri-plugin-sfx = { path = "plugins/sfx" } tauri-plugin-sidecar2 = { path = "plugins/sidecar2" } tauri-plugin-store2 = { path = "plugins/store2" } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5d3c92b2b1..6ee0d8aab0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -70,6 +70,7 @@ "@hypr/plugin-permissions": "workspace:*", "@hypr/plugin-screen": "workspace:*", "@hypr/plugin-settings": "workspace:*", + "@hypr/plugin-shortcut": "workspace:*", "@hypr/plugin-sfx": "workspace:*", "@hypr/plugin-store2": "workspace:*", "@hypr/plugin-tantivy": "workspace:*", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index caccedd07e..8e99214178 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -68,6 +68,7 @@ tauri-plugin-relay = { workspace = true } tauri-plugin-screen = { workspace = true } tauri-plugin-sentry = { workspace = true } tauri-plugin-settings = { workspace = true } +tauri-plugin-shortcut = { workspace = true } tauri-plugin-sfx = { workspace = true } tauri-plugin-shell = { workspace = true } tauri-plugin-sidecar2 = { workspace = true } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index e12ed2849e..bbaf27a528 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -79,6 +79,7 @@ "permissions:default", "screen:default", "settings:default", + "shortcut:default", "sfx:default", "path2:default", "pdf:default", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 75e7323982..a7dcdf2681 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub async fn main() { .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_store2::init()) .plugin(tauri_plugin_settings::init()) + .plugin(tauri_plugin_shortcut::init()) .plugin(tauri_plugin_sfx::init()) .plugin(tauri_plugin_windows::init()) .plugin(tauri_plugin_js::init()) diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index d34c7b8e81..c502a356b0 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -8,7 +8,6 @@ import { } from "lucide-react"; import { Reorder } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useResizeObserver } from "usehooks-ts"; import { useShallow } from "zustand/shallow"; @@ -165,8 +164,6 @@ function Header({ tabs }: { tabs: Tab[] }) { ); const setTabRef = useScrollActiveTabIntoView(regularTabs); - useTabsShortcuts(); - return (
({ - tabs: state.tabs, - currentTab: state.currentTab, - close: state.close, - select: state.select, - selectNext: state.selectNext, - selectPrev: state.selectPrev, - restoreLastClosedTab: state.restoreLastClosedTab, - openNew: state.openNew, - unpin: state.unpin, - setPendingCloseConfirmationTab: state.setPendingCloseConfirmationTab, - })), - ); - const liveSessionId = useListener((state) => state.live.sessionId); - const liveStatus = useListener((state) => state.live.status); - const isListening = liveStatus === "active" || liveStatus === "finalizing"; - const { chat } = useShell(); - - const newNote = useNewNote({ behavior: "new" }); - const newNoteCurrent = useNewNote({ behavior: "current" }); - const newNoteAndListen = useNewNoteAndListen(); - const newEmptyTab = useNewEmptyTab(); - - useHotkeys( - "mod+n", - () => { - if (currentTab?.type === "empty") { - newNoteCurrent(); - } else { - newNote(); - } - }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [currentTab, newNote, newNoteCurrent], - ); - - useHotkeys( - "mod+t", - () => newEmptyTab(), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [newEmptyTab], - ); - - useHotkeys( - "mod+w", - async () => { - if (currentTab) { - const isCurrentTabListening = - isListening && - currentTab.type === "sessions" && - currentTab.id === liveSessionId; - if (isCurrentTabListening) { - setPendingCloseConfirmationTab(currentTab); - } else if (currentTab.pinned) { - unpin(currentTab); - } else { - if (currentTab.type === "chat_support") { - chat.sendEvent({ type: "CLOSE" }); - } - close(currentTab); - } - } - }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [ - currentTab, - close, - unpin, - isListening, - liveSessionId, - setPendingCloseConfirmationTab, - chat, - ], - ); - - useHotkeys( - "mod+1, mod+2, mod+3, mod+4, mod+5, mod+6, mod+7, mod+8, mod+9", - (event) => { - const key = event.key; - const targetIndex = - key === "9" ? tabs.length - 1 : Number.parseInt(key, 10) - 1; - const target = tabs[targetIndex]; - if (target) { - select(target); - } - }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [tabs, select], - ); - - useHotkeys( - "mod+alt+left", - () => selectPrev(), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [selectPrev], - ); - - useHotkeys( - "mod+alt+right", - () => selectNext(), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [selectNext], - ); - - useHotkeys( - "mod+shift+t", - () => restoreLastClosedTab(), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [restoreLastClosedTab], - ); - - useHotkeys( - "mod+shift+c", - () => openNew({ type: "calendar" }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew], - ); - - useHotkeys( - "mod+shift+o", - () => - openNew({ - type: "contacts", - state: { selectedOrganization: null, selectedPerson: null }, - }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew], - ); - - useHotkeys( - "mod+shift+comma", - () => openNew({ type: "ai" }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew], - ); - - useHotkeys( - "mod+shift+l", - () => openNew({ type: "folders", id: null }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew], - ); - - useHotkeys( - "mod+shift+f", - () => openNew({ type: "search" }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openNew], - ); - - useHotkeys( - "mod+shift+n", - () => newNoteAndListen(), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [newNoteAndListen], - ); - - return {}; -} - -function useNewEmptyTab() { +function useNewEmptyTab(){ const openNew = useTabs((state) => state.openNew); const handler = useCallback(() => { diff --git a/apps/desktop/src/contexts/shell/chat.ts b/apps/desktop/src/contexts/shell/chat.ts index 6099f13536..1c9e0815ab 100644 --- a/apps/desktop/src/contexts/shell/chat.ts +++ b/apps/desktop/src/contexts/shell/chat.ts @@ -1,5 +1,3 @@ -import { useHotkeys } from "react-hotkeys-hook"; - import { useChatContext } from "../../store/zustand/chat-context"; import { useTabs } from "../../store/zustand/tabs"; @@ -12,17 +10,6 @@ export function useChatMode() { const groupId = useChatContext((state) => state.groupId); const setGroupId = useChatContext((state) => state.setGroupId); - useHotkeys( - "mod+j", - () => transitionChatMode({ type: "TOGGLE" }), - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [transitionChatMode], - ); - return { mode, sendEvent: transitionChatMode, diff --git a/apps/desktop/src/contexts/shell/settings.ts b/apps/desktop/src/contexts/shell/settings.ts index a859105798..fbdc197f85 100644 --- a/apps/desktop/src/contexts/shell/settings.ts +++ b/apps/desktop/src/contexts/shell/settings.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTabs } from "../../store/zustand/tabs"; @@ -10,17 +9,5 @@ export function useSettings() { openNew({ type: "settings" }); }, [openNew]); - useHotkeys( - "mod+,", - openSettings, - { - preventDefault: true, - splitKey: "|", - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [openSettings], - ); - return { openSettings }; } diff --git a/apps/desktop/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/hooks/useGlobalShortcuts.ts new file mode 100644 index 0000000000..129682e4e8 --- /dev/null +++ b/apps/desktop/src/hooks/useGlobalShortcuts.ts @@ -0,0 +1,162 @@ +import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useShallow } from "zustand/shallow"; + +import { commands as shortcutCommands } from "@hypr/plugin-shortcut"; + +import { useListener } from "../contexts/listener"; +import { useShell } from "../contexts/shell"; +import { useNewNote, useNewNoteAndListen } from "../components/main/shared"; +import { useTabs } from "../store/zustand/tabs"; + +export function useGlobalShortcuts() { + const { data: shortcuts } = useQuery({ + queryKey: ["shortcuts", "all"], + queryFn: () => shortcutCommands.getAllShortcuts(), + staleTime: Number.POSITIVE_INFINITY, + }); + + const { + tabs, + currentTab, + close, + select, + selectNext, + selectPrev, + restoreLastClosedTab, + openNew, + unpin, + setPendingCloseConfirmationTab, + transitionChatMode, + } = useTabs( + useShallow((state) => ({ + tabs: state.tabs, + currentTab: state.currentTab, + close: state.close, + select: state.select, + selectNext: state.selectNext, + selectPrev: state.selectPrev, + restoreLastClosedTab: state.restoreLastClosedTab, + openNew: state.openNew, + unpin: state.unpin, + setPendingCloseConfirmationTab: state.setPendingCloseConfirmationTab, + transitionChatMode: state.transitionChatMode, + })), + ); + + const liveSessionId = useListener((state) => state.live.sessionId); + const liveStatus = useListener((state) => state.live.status); + const isListening = liveStatus === "active" || liveStatus === "finalizing"; + const { chat } = useShell(); + + const newNote = useNewNote({ behavior: "new" }); + const newNoteCurrent = useNewNote({ behavior: "current" }); + const newNoteAndListen = useNewNoteAndListen(); + + const newEmptyTab = useCallback(() => { + openNew({ type: "empty" }); + }, [openNew]); + + const hotkeysOptions = { + preventDefault: true, + enableOnFormTags: true as const, + enableOnContentEditable: true, + }; + + useHotkeys( + "mod+n", + () => { + if (currentTab?.type === "empty") { + newNoteCurrent(); + } else { + newNote(); + } + }, + hotkeysOptions, + [currentTab, newNote, newNoteCurrent], + ); + + useHotkeys( + "mod+t", + () => newEmptyTab(), + hotkeysOptions, + [newEmptyTab], + ); + + useHotkeys( + "mod+w", + async () => { + if (currentTab) { + const isCurrentTabListening = + isListening && + currentTab.type === "sessions" && + currentTab.id === liveSessionId; + if (isCurrentTabListening) { + setPendingCloseConfirmationTab(currentTab); + } else if (currentTab.pinned) { + unpin(currentTab); + } else { + if (currentTab.type === "chat_support") { + chat.sendEvent({ type: "CLOSE" }); + } + close(currentTab); + } + } + }, + hotkeysOptions, + [ + currentTab, + close, + unpin, + isListening, + liveSessionId, + setPendingCloseConfirmationTab, + chat, + ], + ); + + useHotkeys( + "mod+1, mod+2, mod+3, mod+4, mod+5, mod+6, mod+7, mod+8, mod+9", + (event) => { + const key = event.key; + const targetIndex = + key === "9" ? tabs.length - 1 : Number.parseInt(key, 10) - 1; + const target = tabs[targetIndex]; + if (target) { + select(target); + } + }, + hotkeysOptions, + [tabs, select], + ); + + useHotkeys("mod+alt+left", () => selectPrev(), hotkeysOptions, [selectPrev]); + useHotkeys("mod+alt+right", () => selectNext(), hotkeysOptions, [selectNext]); + useHotkeys("mod+shift+t", () => restoreLastClosedTab(), hotkeysOptions, [restoreLastClosedTab]); + useHotkeys("mod+shift+c", () => openNew({ type: "calendar" }), hotkeysOptions, [openNew]); + useHotkeys( + "mod+shift+o", + () => openNew({ type: "contacts", state: { selectedOrganization: null, selectedPerson: null } }), + hotkeysOptions, + [openNew], + ); + useHotkeys("mod+shift+comma", () => openNew({ type: "ai" }), hotkeysOptions, [openNew]); + useHotkeys("mod+shift+l", () => openNew({ type: "folders", id: null }), hotkeysOptions, [openNew]); + useHotkeys("mod+shift+f", () => openNew({ type: "search" }), hotkeysOptions, [openNew]); + useHotkeys("mod+shift+n", () => newNoteAndListen(), hotkeysOptions, [newNoteAndListen]); + useHotkeys( + "mod+j", + () => transitionChatMode({ type: "TOGGLE" }), + hotkeysOptions, + [transitionChatMode], + ); + useHotkeys( + "mod+,", + () => openNew({ type: "settings" }), + { ...hotkeysOptions, splitKey: "|" }, + [openNew], + ); + + return { shortcuts }; +} diff --git a/apps/desktop/src/routes/app/main/_layout.tsx b/apps/desktop/src/routes/app/main/_layout.tsx index 87e3293dc3..047b0afb28 100644 --- a/apps/desktop/src/routes/app/main/_layout.tsx +++ b/apps/desktop/src/routes/app/main/_layout.tsx @@ -17,6 +17,7 @@ import { ShellProvider } from "../../../contexts/shell"; import { useRegisterTools } from "../../../contexts/tool"; import { ToolRegistryProvider } from "../../../contexts/tool"; import { useDeeplinkHandler } from "../../../hooks/useDeeplinkHandler"; +import { useGlobalShortcuts } from "../../../hooks/useGlobalShortcuts"; import { deleteSessionCascade } from "../../../store/tinybase/store/deleteSession"; import * as main from "../../../store/tinybase/store/main"; import { isSessionEmpty } from "../../../store/tinybase/store/sessions"; @@ -119,6 +120,7 @@ function Component() { + @@ -130,6 +132,11 @@ function Component() { ); } +function GlobalShortcuts() { + useGlobalShortcuts(); + return null; +} + function ToolRegistration() { const { search } = useSearchEngine(); const store = main.UI.useStore(main.STORE_ID); diff --git a/plugins/shortcut/.gitignore b/plugins/shortcut/.gitignore new file mode 100644 index 0000000000..50d8e32e89 --- /dev/null +++ b/plugins/shortcut/.gitignore @@ -0,0 +1,17 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist-js +dist diff --git a/plugins/shortcut/Cargo.toml b/plugins/shortcut/Cargo.toml new file mode 100644 index 0000000000..36c32b406c --- /dev/null +++ b/plugins/shortcut/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tauri-plugin-shortcut" +version = "0.1.0" +authors = ["You"] +edition = "2024" +exclude = ["/node_modules"] +links = "tauri-plugin-shortcut" +description = "" + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +specta-typescript = { workspace = true } + +[dependencies] +serde = { workspace = true } +specta = { workspace = true } +tauri = { workspace = true, features = ["test"] } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } diff --git a/plugins/shortcut/build.rs b/plugins/shortcut/build.rs new file mode 100644 index 0000000000..c96e475fc2 --- /dev/null +++ b/plugins/shortcut/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["get_all_shortcuts"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/plugins/shortcut/js/bindings.gen.ts b/plugins/shortcut/js/bindings.gen.ts new file mode 100644 index 0000000000..ee55e497f8 --- /dev/null +++ b/plugins/shortcut/js/bindings.gen.ts @@ -0,0 +1,90 @@ +// @ts-nocheck +/** tauri-specta globals **/ +import { + Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + +export const commands = { + async getAllShortcuts(): Promise { + return await TAURI_INVOKE("plugin:shortcut|get_all_shortcuts"); + }, +}; + +/** user-defined events **/ + +/** user-defined constants **/ + +/** user-defined types **/ + +export type ShortcutCategory = + | "Tabs" + | "Navigation" + | "Editor" + | "Search" + | "View"; +export type ShortcutDef = { + id: string; + keys: string; + category: ShortcutCategory; + description: string; + scope: ShortcutScope; +}; +export type ShortcutScope = "Global" | "Scoped"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +} diff --git a/plugins/shortcut/js/index.ts b/plugins/shortcut/js/index.ts new file mode 100644 index 0000000000..a96e122f03 --- /dev/null +++ b/plugins/shortcut/js/index.ts @@ -0,0 +1 @@ +export * from "./bindings.gen"; diff --git a/plugins/shortcut/package.json b/plugins/shortcut/package.json new file mode 100644 index 0000000000..de4aef8dca --- /dev/null +++ b/plugins/shortcut/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hypr/plugin-shortcut", + "private": true, + "main": "./js/index.ts", + "scripts": { + "codegen": "cargo test -p tauri-plugin-shortcut" + }, + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } +} diff --git a/plugins/shortcut/permissions/autogenerated/commands/get_all_shortcuts.toml b/plugins/shortcut/permissions/autogenerated/commands/get_all_shortcuts.toml new file mode 100644 index 0000000000..909dad474d --- /dev/null +++ b/plugins/shortcut/permissions/autogenerated/commands/get_all_shortcuts.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-all-shortcuts" +description = "Enables the get_all_shortcuts command without any pre-configured scope." +commands.allow = ["get_all_shortcuts"] + +[[permission]] +identifier = "deny-get-all-shortcuts" +description = "Denies the get_all_shortcuts command without any pre-configured scope." +commands.deny = ["get_all_shortcuts"] diff --git a/plugins/shortcut/permissions/autogenerated/reference.md b/plugins/shortcut/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..7566cdf03d --- /dev/null +++ b/plugins/shortcut/permissions/autogenerated/reference.md @@ -0,0 +1,43 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-get-all-shortcuts` + +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`shortcut:allow-get-all-shortcuts` + + + +Enables the get_all_shortcuts command without any pre-configured scope. + +
+ +`shortcut:deny-get-all-shortcuts` + + + +Denies the get_all_shortcuts command without any pre-configured scope. + +
diff --git a/plugins/shortcut/permissions/default.toml b/plugins/shortcut/permissions/default.toml new file mode 100644 index 0000000000..9e18286d60 --- /dev/null +++ b/plugins/shortcut/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-get-all-shortcuts"] diff --git a/plugins/shortcut/permissions/schemas/schema.json b/plugins/shortcut/permissions/schemas/schema.json new file mode 100644 index 0000000000..057f9e2711 --- /dev/null +++ b/plugins/shortcut/permissions/schemas/schema.json @@ -0,0 +1,318 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the get_all_shortcuts command without any pre-configured scope.", + "type": "string", + "const": "allow-get-all-shortcuts", + "markdownDescription": "Enables the get_all_shortcuts command without any pre-configured scope." + }, + { + "description": "Denies the get_all_shortcuts command without any pre-configured scope.", + "type": "string", + "const": "deny-get-all-shortcuts", + "markdownDescription": "Denies the get_all_shortcuts command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-all-shortcuts`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-all-shortcuts`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/shortcut/src/commands.rs b/plugins/shortcut/src/commands.rs new file mode 100644 index 0000000000..d9989efaf7 --- /dev/null +++ b/plugins/shortcut/src/commands.rs @@ -0,0 +1,8 @@ +use crate::registry; +use crate::types::ShortcutDef; + +#[tauri::command] +#[specta::specta] +pub(crate) fn get_all_shortcuts() -> Vec { + registry::all() +} diff --git a/plugins/shortcut/src/lib.rs b/plugins/shortcut/src/lib.rs new file mode 100644 index 0000000000..27c888aa6c --- /dev/null +++ b/plugins/shortcut/src/lib.rs @@ -0,0 +1,45 @@ +mod commands; +pub mod registry; +pub mod types; + +pub use types::*; + +const PLUGIN_NAME: &str = "shortcut"; + +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .plugin_name(PLUGIN_NAME) + .commands(tauri_specta::collect_commands![ + commands::get_all_shortcuts, + ]) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + let specta_builder = make_specta_builder(); + + tauri::plugin::Builder::new(PLUGIN_NAME) + .invoke_handler(specta_builder.invoke_handler()) + .build() +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn export_types() { + const OUTPUT_FILE: &str = "./js/bindings.gen.ts"; + + make_specta_builder::() + .export( + specta_typescript::Typescript::default() + .formatter(specta_typescript::formatter::prettier) + .bigint(specta_typescript::BigIntExportBehavior::Number), + OUTPUT_FILE, + ) + .unwrap(); + + let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); + std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); + } +} diff --git a/plugins/shortcut/src/registry.rs b/plugins/shortcut/src/registry.rs new file mode 100644 index 0000000000..a9a922435a --- /dev/null +++ b/plugins/shortcut/src/registry.rs @@ -0,0 +1,251 @@ +use crate::types::{ShortcutCategory, ShortcutDef, ShortcutScope}; + +pub fn all() -> Vec { + vec![ + ShortcutDef { + id: "new_note".into(), + keys: "mod+n".into(), + category: ShortcutCategory::Tabs, + description: "Create a new note".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "new_empty_tab".into(), + keys: "mod+t".into(), + category: ShortcutCategory::Tabs, + description: "Open a new empty tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "close_tab".into(), + keys: "mod+w".into(), + category: ShortcutCategory::Tabs, + description: "Close current tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_1".into(), + keys: "mod+1".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 1".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_2".into(), + keys: "mod+2".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 2".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_3".into(), + keys: "mod+3".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 3".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_4".into(), + keys: "mod+4".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 4".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_5".into(), + keys: "mod+5".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 5".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_6".into(), + keys: "mod+6".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 6".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_7".into(), + keys: "mod+7".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 7".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_8".into(), + keys: "mod+8".into(), + category: ShortcutCategory::Tabs, + description: "Switch to tab 8".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "select_tab_9".into(), + keys: "mod+9".into(), + category: ShortcutCategory::Tabs, + description: "Switch to last tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "prev_tab".into(), + keys: "mod+alt+left".into(), + category: ShortcutCategory::Navigation, + description: "Switch to previous tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "next_tab".into(), + keys: "mod+alt+right".into(), + category: ShortcutCategory::Navigation, + description: "Switch to next tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "restore_closed_tab".into(), + keys: "mod+shift+t".into(), + category: ShortcutCategory::Tabs, + description: "Restore last closed tab".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_calendar".into(), + keys: "mod+shift+c".into(), + category: ShortcutCategory::Navigation, + description: "Open calendar".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_contacts".into(), + keys: "mod+shift+o".into(), + category: ShortcutCategory::Navigation, + description: "Open contacts".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_ai_settings".into(), + keys: "mod+shift+comma".into(), + category: ShortcutCategory::Navigation, + description: "Open AI settings".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_folders".into(), + keys: "mod+shift+l".into(), + category: ShortcutCategory::Navigation, + description: "Open folders".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_search".into(), + keys: "mod+shift+f".into(), + category: ShortcutCategory::Search, + description: "Open advanced search".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "new_note_and_listen".into(), + keys: "mod+shift+n".into(), + category: ShortcutCategory::Tabs, + description: "Create a new note and start listening".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "toggle_chat".into(), + keys: "mod+j".into(), + category: ShortcutCategory::View, + description: "Toggle chat panel".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "open_settings".into(), + keys: "mod+,".into(), + category: ShortcutCategory::Navigation, + description: "Open settings".into(), + scope: ShortcutScope::Global, + }, + ShortcutDef { + id: "toggle_sidebar".into(), + keys: "mod+\\".into(), + category: ShortcutCategory::View, + description: "Toggle sidebar".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "focus_search".into(), + keys: "mod+k".into(), + category: ShortcutCategory::Search, + description: "Focus search input".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "open_note_dialog".into(), + keys: "mod+o".into(), + category: ShortcutCategory::Navigation, + description: "Open note dialog".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "switch_to_enhanced".into(), + keys: "alt+s".into(), + category: ShortcutCategory::Editor, + description: "Switch to enhanced editor tab".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "switch_to_raw".into(), + keys: "alt+m".into(), + category: ShortcutCategory::Editor, + description: "Switch to raw editor tab".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "switch_to_transcript".into(), + keys: "alt+t".into(), + category: ShortcutCategory::Editor, + description: "Switch to transcript tab".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "prev_panel_tab".into(), + keys: "ctrl+alt+left".into(), + category: ShortcutCategory::Navigation, + description: "Switch to previous panel tab".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "next_panel_tab".into(), + keys: "ctrl+alt+right".into(), + category: ShortcutCategory::Navigation, + description: "Switch to next panel tab".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "transcript_search".into(), + keys: "mod+f".into(), + category: ShortcutCategory::Search, + description: "Search in transcript".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "undo_delete".into(), + keys: "mod+z".into(), + category: ShortcutCategory::Tabs, + description: "Undo delete".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "dismiss".into(), + keys: "esc".into(), + category: ShortcutCategory::View, + description: "Dismiss / close".into(), + scope: ShortcutScope::Scoped, + }, + ShortcutDef { + id: "play_pause_audio".into(), + keys: "space".into(), + category: ShortcutCategory::Editor, + description: "Play or pause audio playback".into(), + scope: ShortcutScope::Scoped, + }, + ] +} diff --git a/plugins/shortcut/src/types.rs b/plugins/shortcut/src/types.rs new file mode 100644 index 0000000000..f3ee29b928 --- /dev/null +++ b/plugins/shortcut/src/types.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Serialize, Deserialize, Clone, Type)] +pub struct ShortcutDef { + pub id: String, + pub keys: String, + pub category: ShortcutCategory, + pub description: String, + pub scope: ShortcutScope, +} + +#[derive(Serialize, Deserialize, Clone, Type)] +pub enum ShortcutCategory { + Tabs, + Navigation, + Editor, + Search, + View, +} + +#[derive(Serialize, Deserialize, Clone, Type)] +pub enum ShortcutScope { + Global, + Scoped, +} diff --git a/plugins/shortcut/tsconfig.json b/plugins/shortcut/tsconfig.json new file mode 100644 index 0000000000..13b985325d --- /dev/null +++ b/plugins/shortcut/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./js/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c52b67c488..288316853d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: '@hypr/plugin-sfx': specifier: workspace:* version: link:../../plugins/sfx + '@hypr/plugin-shortcut': + specifier: workspace:* + version: link:../../plugins/shortcut '@hypr/plugin-store2': specifier: workspace:* version: link:../../plugins/store2 @@ -1880,6 +1883,12 @@ importers: specifier: ^2.10.1 version: 2.10.1 + plugins/shortcut: + dependencies: + '@tauri-apps/api': + specifier: ^2.10.1 + version: 2.10.1 + plugins/sidecar2: dependencies: '@tauri-apps/api': From 811222071d7a9919be976bea6fa2efaec87be06e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:42:30 +0000 Subject: [PATCH 02/11] fix: address dprint formatting issues Co-Authored-By: yujonglee --- Cargo.toml | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- .../src/components/main/body/index.tsx | 2 +- apps/desktop/src/hooks/useGlobalShortcuts.ts | 45 +++++++++++++------ plugins/shortcut/src/lib.rs | 4 +- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5d2a881268..7ff42eb5e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,8 +185,8 @@ tauri-plugin-permissions = { path = "plugins/permissions" } tauri-plugin-relay = { path = "plugins/relay" } tauri-plugin-screen = { path = "plugins/screen" } tauri-plugin-settings = { path = "plugins/settings" } -tauri-plugin-shortcut = { path = "plugins/shortcut" } tauri-plugin-sfx = { path = "plugins/sfx" } +tauri-plugin-shortcut = { path = "plugins/shortcut" } tauri-plugin-sidecar2 = { path = "plugins/sidecar2" } tauri-plugin-store2 = { path = "plugins/store2" } tauri-plugin-tantivy = { path = "plugins/tantivy" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8e99214178..4373187cf5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -68,9 +68,9 @@ tauri-plugin-relay = { workspace = true } tauri-plugin-screen = { workspace = true } tauri-plugin-sentry = { workspace = true } tauri-plugin-settings = { workspace = true } -tauri-plugin-shortcut = { workspace = true } tauri-plugin-sfx = { workspace = true } tauri-plugin-shell = { workspace = true } +tauri-plugin-shortcut = { workspace = true } tauri-plugin-sidecar2 = { workspace = true } tauri-plugin-single-instance = { workspace = true } tauri-plugin-store = { workspace = true } diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index c502a356b0..4b5715cc4f 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -808,7 +808,7 @@ function useScrollActiveTabIntoView(tabs: Tab[]) { return setTabRef; } -function useNewEmptyTab(){ +function useNewEmptyTab() { const openNew = useTabs((state) => state.openNew); const handler = useCallback(() => { diff --git a/apps/desktop/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/hooks/useGlobalShortcuts.ts index 129682e4e8..332c19b874 100644 --- a/apps/desktop/src/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/hooks/useGlobalShortcuts.ts @@ -5,9 +5,9 @@ import { useShallow } from "zustand/shallow"; import { commands as shortcutCommands } from "@hypr/plugin-shortcut"; +import { useNewNote, useNewNoteAndListen } from "../components/main/shared"; import { useListener } from "../contexts/listener"; import { useShell } from "../contexts/shell"; -import { useNewNote, useNewNoteAndListen } from "../components/main/shared"; import { useTabs } from "../store/zustand/tabs"; export function useGlobalShortcuts() { @@ -77,12 +77,7 @@ export function useGlobalShortcuts() { [currentTab, newNote, newNoteCurrent], ); - useHotkeys( - "mod+t", - () => newEmptyTab(), - hotkeysOptions, - [newEmptyTab], - ); + useHotkeys("mod+t", () => newEmptyTab(), hotkeysOptions, [newEmptyTab]); useHotkeys( "mod+w", @@ -133,18 +128,40 @@ export function useGlobalShortcuts() { useHotkeys("mod+alt+left", () => selectPrev(), hotkeysOptions, [selectPrev]); useHotkeys("mod+alt+right", () => selectNext(), hotkeysOptions, [selectNext]); - useHotkeys("mod+shift+t", () => restoreLastClosedTab(), hotkeysOptions, [restoreLastClosedTab]); - useHotkeys("mod+shift+c", () => openNew({ type: "calendar" }), hotkeysOptions, [openNew]); + useHotkeys("mod+shift+t", () => restoreLastClosedTab(), hotkeysOptions, [ + restoreLastClosedTab, + ]); + useHotkeys( + "mod+shift+c", + () => openNew({ type: "calendar" }), + hotkeysOptions, + [openNew], + ); useHotkeys( "mod+shift+o", - () => openNew({ type: "contacts", state: { selectedOrganization: null, selectedPerson: null } }), + () => + openNew({ + type: "contacts", + state: { selectedOrganization: null, selectedPerson: null }, + }), + hotkeysOptions, + [openNew], + ); + useHotkeys("mod+shift+comma", () => openNew({ type: "ai" }), hotkeysOptions, [ + openNew, + ]); + useHotkeys( + "mod+shift+l", + () => openNew({ type: "folders", id: null }), hotkeysOptions, [openNew], ); - useHotkeys("mod+shift+comma", () => openNew({ type: "ai" }), hotkeysOptions, [openNew]); - useHotkeys("mod+shift+l", () => openNew({ type: "folders", id: null }), hotkeysOptions, [openNew]); - useHotkeys("mod+shift+f", () => openNew({ type: "search" }), hotkeysOptions, [openNew]); - useHotkeys("mod+shift+n", () => newNoteAndListen(), hotkeysOptions, [newNoteAndListen]); + useHotkeys("mod+shift+f", () => openNew({ type: "search" }), hotkeysOptions, [ + openNew, + ]); + useHotkeys("mod+shift+n", () => newNoteAndListen(), hotkeysOptions, [ + newNoteAndListen, + ]); useHotkeys( "mod+j", () => transitionChatMode({ type: "TOGGLE" }), diff --git a/plugins/shortcut/src/lib.rs b/plugins/shortcut/src/lib.rs index 27c888aa6c..cedc5dc2df 100644 --- a/plugins/shortcut/src/lib.rs +++ b/plugins/shortcut/src/lib.rs @@ -9,9 +9,7 @@ const PLUGIN_NAME: &str = "shortcut"; fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) - .commands(tauri_specta::collect_commands![ - commands::get_all_shortcuts, - ]) + .commands(tauri_specta::collect_commands![commands::get_all_shortcuts,]) } pub fn init() -> tauri::plugin::TauriPlugin { From 8fdffff1e834cdc71aecfe9c216d36f5009a8737 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:03:24 +0000 Subject: [PATCH 03/11] fix: add tauri-plugin-shortcut to Windows CI exclude list Co-Authored-By: yujonglee --- .github/workflows/desktop_ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/desktop_ci.yaml b/.github/workflows/desktop_ci.yaml index 70833ccdc3..69021a228d 100644 --- a/.github/workflows/desktop_ci.yaml +++ b/.github/workflows/desktop_ci.yaml @@ -132,6 +132,7 @@ jobs: --exclude tauri-plugin-permissions \ --exclude tauri-plugin-screen \ --exclude tauri-plugin-settings \ + --exclude tauri-plugin-shortcut \ --exclude tauri-plugin-sfx \ --exclude tauri-plugin-sidecar2 \ --exclude tauri-plugin-store2 \ From dec3047cb69db26f7374c1a697d69de41f9b9e3c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:06:13 +0000 Subject: [PATCH 04/11] refactor: read shortcut keys from Rust registry instead of hardcoding in TS - Create useShortcutRegistry hook with shared query and keysMap lookup - Refactor useGlobalShortcuts to use registry keys via k(id) helper - Update all scoped shortcuts (leftsidebar, search, empty, note-input, settings, ai, transcript search, audio playback, undo-delete) to read keys from the Rust registry - Add enabled guards so shortcuts only activate after registry loads Co-Authored-By: yujonglee --- apps/desktop/src/components/main/body/ai.tsx | 10 ++- .../src/components/main/body/empty/index.tsx | 11 ++- .../main/body/sessions/note-input/index.tsx | 53 +++++------- .../note-input/transcript/search-context.tsx | 7 +- .../note-input/transcript/shared/index.tsx | 7 +- .../src/components/main/body/settings.tsx | 10 ++- .../main/sidebar/toast/undo-delete-toast.tsx | 6 +- apps/desktop/src/contexts/search/ui.tsx | 6 +- .../desktop/src/contexts/shell/leftsidebar.ts | 7 +- apps/desktop/src/hooks/useGlobalShortcuts.ts | 86 ++++++++++++------- apps/desktop/src/hooks/useShortcutRegistry.ts | 24 ++++++ 11 files changed, 153 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/src/hooks/useShortcutRegistry.ts diff --git a/apps/desktop/src/components/main/body/ai.tsx b/apps/desktop/src/components/main/body/ai.tsx index 860b65d081..275cec1099 100644 --- a/apps/desktop/src/components/main/body/ai.tsx +++ b/apps/desktop/src/components/main/body/ai.tsx @@ -20,6 +20,7 @@ import { import { cn } from "@hypr/utils"; import { useSettingsNavigation } from "../../../hooks/useSettingsNavigation"; +import { useShortcutKeys } from "../../../hooks/useShortcutRegistry"; import * as main from "../../../store/tinybase/store/main"; import { type Tab, useTabs } from "../../../store/zustand/tabs"; import { LLM } from "../../settings/ai/llm"; @@ -107,8 +108,11 @@ function AIView({ tab }: { tab: Extract }) { ]; const currentIndex = enabledMenuKeys.indexOf(activeTab); + const prevPanelTabKeys = useShortcutKeys("prev_panel_tab"); + const nextPanelTabKeys = useShortcutKeys("next_panel_tab"); + useHotkeys( - "ctrl+alt+left", + prevPanelTabKeys, () => { if (currentIndex > 0) { setActiveTab(enabledMenuKeys[currentIndex - 1]); @@ -118,12 +122,13 @@ function AIView({ tab }: { tab: Extract }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!prevPanelTabKeys, }, [currentIndex, setActiveTab], ); useHotkeys( - "ctrl+alt+right", + nextPanelTabKeys, () => { if (currentIndex >= 0 && currentIndex < enabledMenuKeys.length - 1) { setActiveTab(enabledMenuKeys[currentIndex + 1]); @@ -133,6 +138,7 @@ function AIView({ tab }: { tab: Extract }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!nextPanelTabKeys, }, [currentIndex, setActiveTab], ); diff --git a/apps/desktop/src/components/main/body/empty/index.tsx b/apps/desktop/src/components/main/body/empty/index.tsx index 82a645f016..c408bedc34 100644 --- a/apps/desktop/src/components/main/body/empty/index.tsx +++ b/apps/desktop/src/components/main/body/empty/index.tsx @@ -5,6 +5,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { Kbd } from "@hypr/ui/components/ui/kbd"; import { cn } from "@hypr/utils"; +import { useShortcutKeys } from "../../../../hooks/useShortcutRegistry"; import { type Tab, useTabs } from "../../../../store/zustand/tabs"; import { useNewNote } from "../../shared"; import { StandardTabWrapper } from "../index"; @@ -77,10 +78,16 @@ function EmptyView() { [openCurrent], ); + const openNoteDialogKeys = useShortcutKeys("open_note_dialog"); + useHotkeys( - "mod+o", + openNoteDialogKeys, () => setOpenNoteDialogOpen(true), - { preventDefault: true, enableOnFormTags: true }, + { + preventDefault: true, + enableOnFormTags: true, + enabled: !!openNoteDialogKeys, + }, [setOpenNoteDialogOpen], ); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx index b8cf979d72..c54f639645 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx @@ -20,6 +20,7 @@ import { cn } from "@hypr/utils"; import { useListener } from "../../../../../contexts/listener"; import { useScrollPreservation } from "../../../../../hooks/useScrollPreservation"; +import { useShortcutKeys } from "../../../../../hooks/useShortcutRegistry"; import { type Tab, useTabs } from "../../../../../store/zustand/tabs"; import { type EditorView } from "../../../../../store/zustand/tabs/schema"; import { useCaretNearBottom } from "../caret-position-context"; @@ -293,8 +294,20 @@ function useTabShortcuts({ currentTab: EditorView; handleTabChange: (view: EditorView) => void; }) { + const switchToEnhancedKeys = useShortcutKeys("switch_to_enhanced"); + const switchToRawKeys = useShortcutKeys("switch_to_raw"); + const switchToTranscriptKeys = useShortcutKeys("switch_to_transcript"); + const prevPanelTabKeys = useShortcutKeys("prev_panel_tab"); + const nextPanelTabKeys = useShortcutKeys("next_panel_tab"); + + const scopedOptions = { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }; + useHotkeys( - "alt+s", + switchToEnhancedKeys, () => { const enhancedTabs = editorTabs.filter((t) => t.type === "enhanced"); if (enhancedTabs.length === 0) return; @@ -309,48 +322,36 @@ function useTabShortcuts({ handleTabChange(enhancedTabs[0]); } }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, + { ...scopedOptions, enabled: !!switchToEnhancedKeys }, [currentTab, editorTabs, handleTabChange], ); useHotkeys( - "alt+m", + switchToRawKeys, () => { const rawTab = editorTabs.find((t) => t.type === "raw"); if (rawTab && currentTab.type !== "raw") { handleTabChange(rawTab); } }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, + { ...scopedOptions, enabled: !!switchToRawKeys }, [currentTab, editorTabs, handleTabChange], ); useHotkeys( - "alt+t", + switchToTranscriptKeys, () => { const transcriptTab = editorTabs.find((t) => t.type === "transcript"); if (transcriptTab && currentTab.type !== "transcript") { handleTabChange(transcriptTab); } }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, + { ...scopedOptions, enabled: !!switchToTranscriptKeys }, [currentTab, editorTabs, handleTabChange], ); useHotkeys( - "ctrl+alt+left", + prevPanelTabKeys, () => { const currentIndex = editorTabs.findIndex( (t) => @@ -363,16 +364,12 @@ function useTabShortcuts({ handleTabChange(editorTabs[currentIndex - 1]); } }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, + { ...scopedOptions, enabled: !!prevPanelTabKeys }, [currentTab, editorTabs, handleTabChange], ); useHotkeys( - "ctrl+alt+right", + nextPanelTabKeys, () => { const currentIndex = editorTabs.findIndex( (t) => @@ -385,11 +382,7 @@ function useTabShortcuts({ handleTabChange(editorTabs[currentIndex + 1]); } }, - { - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, + { ...scopedOptions, enabled: !!nextPanelTabKeys }, [currentTab, editorTabs, handleTabChange], ); } diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx index ebb80d9fd4..16affa02c1 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx @@ -9,6 +9,8 @@ import { } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useShortcutKeys } from "../../../../../../hooks/useShortcutRegistry"; + interface SearchContextValue { query: string; isVisible: boolean; @@ -117,8 +119,10 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { setIsVisible(false); }, []); + const transcriptSearchKeys = useShortcutKeys("transcript_search"); + useHotkeys( - "mod+f", + transcriptSearchKeys, (event) => { event.preventDefault(); const container = ensureContainer(); @@ -132,6 +136,7 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!transcriptSearchKeys, }, [ensureContainer], ); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx index 94eea4354d..ca694a915c 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx @@ -8,6 +8,7 @@ import { cn } from "@hypr/utils"; import { useAudioPlayer } from "../../../../../../../contexts/audio-player/provider"; import { useListener } from "../../../../../../../contexts/listener"; +import { useShortcutKeys } from "../../../../../../../hooks/useShortcutRegistry"; import * as main from "../../../../../../../store/tinybase/store/main"; import { TranscriptEmptyState } from "../empty-state"; import { @@ -102,8 +103,10 @@ export function TranscriptContainer({ const currentMs = time.current * 1000; const isPlaying = playerState === "playing"; + const playPauseKeys = useShortcutKeys("play_pause_audio"); + useHotkeys( - "space", + playPauseKeys, (e) => { e.preventDefault(); if (playerState === "playing") { @@ -114,7 +117,7 @@ export function TranscriptContainer({ start(); } }, - { enableOnFormTags: false }, + { enableOnFormTags: false, enabled: !!playPauseKeys }, ); usePlaybackAutoScroll(containerRef, currentMs, isPlaying); diff --git a/apps/desktop/src/components/main/body/settings.tsx b/apps/desktop/src/components/main/body/settings.tsx index 7a4b963826..0600e5d48e 100644 --- a/apps/desktop/src/components/main/body/settings.tsx +++ b/apps/desktop/src/components/main/body/settings.tsx @@ -17,6 +17,7 @@ import { import { cn } from "@hypr/utils"; import { useSettingsNavigation } from "../../../hooks/useSettingsNavigation"; +import { useShortcutKeys } from "../../../hooks/useShortcutRegistry"; import { type SettingsTab, type Tab, @@ -100,8 +101,11 @@ function SettingsView({ tab }: { tab: Extract }) { const currentIndex = SECTIONS.findIndex((s) => s.id === activeTab); + const prevPanelTabKeys = useShortcutKeys("prev_panel_tab"); + const nextPanelTabKeys = useShortcutKeys("next_panel_tab"); + useHotkeys( - "ctrl+alt+left", + prevPanelTabKeys, () => { if (currentIndex > 0) { setActiveTab(SECTIONS[currentIndex - 1].id); @@ -111,12 +115,13 @@ function SettingsView({ tab }: { tab: Extract }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!prevPanelTabKeys, }, [currentIndex, setActiveTab], ); useHotkeys( - "ctrl+alt+right", + nextPanelTabKeys, () => { if (currentIndex < SECTIONS.length - 1) { setActiveTab(SECTIONS[currentIndex + 1].id); @@ -126,6 +131,7 @@ function SettingsView({ tab }: { tab: Extract }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!nextPanelTabKeys, }, [currentIndex, setActiveTab], ); diff --git a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx index 50ccc6c2b9..464be6eb27 100644 --- a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx +++ b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx @@ -6,6 +6,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { cn } from "@hypr/utils"; +import { useShortcutKeys } from "../../../../hooks/useShortcutRegistry"; import { restoreSessionData } from "../../../../store/tinybase/store/deleteSession"; import * as main from "../../../../store/tinybase/store/main"; import { useTabs } from "../../../../store/zustand/tabs"; @@ -213,8 +214,10 @@ export function UndoDeleteKeyboardHandler() { return groups[groups.length - 1]; }, [groups]); + const undoDeleteKeys = useShortcutKeys("undo_delete"); + useHotkeys( - "mod+z", + undoDeleteKeys, () => { if (latestGroup) { restoreGroup(latestGroup); @@ -224,6 +227,7 @@ export function UndoDeleteKeyboardHandler() { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!undoDeleteKeys, }, [latestGroup, restoreGroup], ); diff --git a/apps/desktop/src/contexts/search/ui.tsx b/apps/desktop/src/contexts/search/ui.tsx index 02d47d24ea..f79d36019f 100644 --- a/apps/desktop/src/contexts/search/ui.tsx +++ b/apps/desktop/src/contexts/search/ui.tsx @@ -12,6 +12,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { useShortcutKeys } from "../../hooks/useShortcutRegistry"; import type { SearchDocument, SearchEntityType, @@ -189,10 +190,13 @@ export function SearchUIProvider({ children }: { children: React.ReactNode }) { focusImplRef.current = impl; }, []); - useHotkeys("mod+k", () => focus(), { + const focusSearchKeys = useShortcutKeys("focus_search"); + + useHotkeys(focusSearchKeys, () => focus(), { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!focusSearchKeys, }); useHotkeys( diff --git a/apps/desktop/src/contexts/shell/leftsidebar.ts b/apps/desktop/src/contexts/shell/leftsidebar.ts index 4b76d8a2cd..1b7c94ea1b 100644 --- a/apps/desktop/src/contexts/shell/leftsidebar.ts +++ b/apps/desktop/src/contexts/shell/leftsidebar.ts @@ -1,6 +1,8 @@ import { useCallback, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useShortcutKeys } from "../../hooks/useShortcutRegistry"; + export function useLeftSidebar() { const [expanded, setExpanded] = useState(true); const [showDevtool, setShowDevtool] = useState(false); @@ -13,13 +15,16 @@ export function useLeftSidebar() { setShowDevtool((prev) => !prev); }, []); + const toggleSidebarKeys = useShortcutKeys("toggle_sidebar"); + useHotkeys( - "mod+\\", + toggleSidebarKeys, toggleExpanded, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, + enabled: !!toggleSidebarKeys, }, [toggleExpanded], ); diff --git a/apps/desktop/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/hooks/useGlobalShortcuts.ts index 332c19b874..d5bce7b4df 100644 --- a/apps/desktop/src/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/hooks/useGlobalShortcuts.ts @@ -1,21 +1,17 @@ -import { useQuery } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useShallow } from "zustand/shallow"; -import { commands as shortcutCommands } from "@hypr/plugin-shortcut"; - import { useNewNote, useNewNoteAndListen } from "../components/main/shared"; import { useListener } from "../contexts/listener"; import { useShell } from "../contexts/shell"; import { useTabs } from "../store/zustand/tabs"; +import { useShortcutRegistry } from "./useShortcutRegistry"; export function useGlobalShortcuts() { - const { data: shortcuts } = useQuery({ - queryKey: ["shortcuts", "all"], - queryFn: () => shortcutCommands.getAllShortcuts(), - staleTime: Number.POSITIVE_INFINITY, - }); + const { shortcuts, keysMap } = useShortcutRegistry(); + + const k = useCallback((id: string) => keysMap.get(id) ?? "", [keysMap]); const { tabs, @@ -58,14 +54,17 @@ export function useGlobalShortcuts() { openNew({ type: "empty" }); }, [openNew]); + const ready = keysMap.size > 0; + const hotkeysOptions = { preventDefault: true, enableOnFormTags: true as const, enableOnContentEditable: true, + enabled: ready, }; useHotkeys( - "mod+n", + k("new_note"), () => { if (currentTab?.type === "empty") { newNoteCurrent(); @@ -77,10 +76,12 @@ export function useGlobalShortcuts() { [currentTab, newNote, newNoteCurrent], ); - useHotkeys("mod+t", () => newEmptyTab(), hotkeysOptions, [newEmptyTab]); + useHotkeys(k("new_empty_tab"), () => newEmptyTab(), hotkeysOptions, [ + newEmptyTab, + ]); useHotkeys( - "mod+w", + k("close_tab"), async () => { if (currentTab) { const isCurrentTabListening = @@ -111,8 +112,17 @@ export function useGlobalShortcuts() { ], ); + const selectTabKeys = useMemo( + () => + [1, 2, 3, 4, 5, 6, 7, 8, 9] + .map((i) => k(`select_tab_${i}`)) + .filter(Boolean) + .join(", "), + [k], + ); + useHotkeys( - "mod+1, mod+2, mod+3, mod+4, mod+5, mod+6, mod+7, mod+8, mod+9", + selectTabKeys, (event) => { const key = event.key; const targetIndex = @@ -126,19 +136,22 @@ export function useGlobalShortcuts() { [tabs, select], ); - useHotkeys("mod+alt+left", () => selectPrev(), hotkeysOptions, [selectPrev]); - useHotkeys("mod+alt+right", () => selectNext(), hotkeysOptions, [selectNext]); - useHotkeys("mod+shift+t", () => restoreLastClosedTab(), hotkeysOptions, [ - restoreLastClosedTab, - ]); + useHotkeys(k("prev_tab"), () => selectPrev(), hotkeysOptions, [selectPrev]); + useHotkeys(k("next_tab"), () => selectNext(), hotkeysOptions, [selectNext]); useHotkeys( - "mod+shift+c", + k("restore_closed_tab"), + () => restoreLastClosedTab(), + hotkeysOptions, + [restoreLastClosedTab], + ); + useHotkeys( + k("open_calendar"), () => openNew({ type: "calendar" }), hotkeysOptions, [openNew], ); useHotkeys( - "mod+shift+o", + k("open_contacts"), () => openNew({ type: "contacts", @@ -147,29 +160,38 @@ export function useGlobalShortcuts() { hotkeysOptions, [openNew], ); - useHotkeys("mod+shift+comma", () => openNew({ type: "ai" }), hotkeysOptions, [ - openNew, - ]); useHotkeys( - "mod+shift+l", + k("open_ai_settings"), + () => openNew({ type: "ai" }), + hotkeysOptions, + [openNew], + ); + useHotkeys( + k("open_folders"), () => openNew({ type: "folders", id: null }), hotkeysOptions, [openNew], ); - useHotkeys("mod+shift+f", () => openNew({ type: "search" }), hotkeysOptions, [ - openNew, - ]); - useHotkeys("mod+shift+n", () => newNoteAndListen(), hotkeysOptions, [ - newNoteAndListen, - ]); useHotkeys( - "mod+j", + k("open_search"), + () => openNew({ type: "search" }), + hotkeysOptions, + [openNew], + ); + useHotkeys( + k("new_note_and_listen"), + () => newNoteAndListen(), + hotkeysOptions, + [newNoteAndListen], + ); + useHotkeys( + k("toggle_chat"), () => transitionChatMode({ type: "TOGGLE" }), hotkeysOptions, [transitionChatMode], ); useHotkeys( - "mod+,", + k("open_settings"), () => openNew({ type: "settings" }), { ...hotkeysOptions, splitKey: "|" }, [openNew], diff --git a/apps/desktop/src/hooks/useShortcutRegistry.ts b/apps/desktop/src/hooks/useShortcutRegistry.ts new file mode 100644 index 0000000000..128f1bd3f0 --- /dev/null +++ b/apps/desktop/src/hooks/useShortcutRegistry.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { commands as shortcutCommands } from "@hypr/plugin-shortcut"; + +export function useShortcutRegistry() { + const { data: shortcuts } = useQuery({ + queryKey: ["shortcuts", "all"], + queryFn: () => shortcutCommands.getAllShortcuts(), + staleTime: Number.POSITIVE_INFINITY, + }); + + const keysMap = useMemo(() => { + if (!shortcuts) return new Map(); + return new Map(shortcuts.map((s) => [s.id, s.keys])); + }, [shortcuts]); + + return { shortcuts, keysMap }; +} + +export function useShortcutKeys(id: string): string { + const { keysMap } = useShortcutRegistry(); + return keysMap.get(id) ?? ""; +} From 56fe3ed9cbd3c767dbfc0fb7a6024518cbd53cad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:48:15 +0000 Subject: [PATCH 05/11] refactor: use ShortcutId enum for type-safe shortcut keys Co-Authored-By: yujonglee --- apps/desktop/src/hooks/useGlobalShortcuts.ts | 20 ++- apps/desktop/src/hooks/useShortcutRegistry.ts | 9 +- plugins/shortcut/js/bindings.gen.ts | 129 +++++++++--------- plugins/shortcut/src/registry.rs | 72 +++++----- plugins/shortcut/src/types.rs | 42 +++++- 5 files changed, 163 insertions(+), 109 deletions(-) diff --git a/apps/desktop/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/hooks/useGlobalShortcuts.ts index d5bce7b4df..2ce4b117c0 100644 --- a/apps/desktop/src/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/hooks/useGlobalShortcuts.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useShallow } from "zustand/shallow"; +import type { ShortcutId } from "@hypr/plugin-shortcut"; + import { useNewNote, useNewNoteAndListen } from "../components/main/shared"; import { useListener } from "../contexts/listener"; import { useShell } from "../contexts/shell"; @@ -11,7 +13,7 @@ import { useShortcutRegistry } from "./useShortcutRegistry"; export function useGlobalShortcuts() { const { shortcuts, keysMap } = useShortcutRegistry(); - const k = useCallback((id: string) => keysMap.get(id) ?? "", [keysMap]); + const k = useCallback((id: ShortcutId) => keysMap.get(id) ?? "", [keysMap]); const { tabs, @@ -112,10 +114,22 @@ export function useGlobalShortcuts() { ], ); + const selectTabIds: ShortcutId[] = [ + "select_tab_1", + "select_tab_2", + "select_tab_3", + "select_tab_4", + "select_tab_5", + "select_tab_6", + "select_tab_7", + "select_tab_8", + "select_tab_9", + ]; + const selectTabKeys = useMemo( () => - [1, 2, 3, 4, 5, 6, 7, 8, 9] - .map((i) => k(`select_tab_${i}`)) + selectTabIds + .map((id) => k(id)) .filter(Boolean) .join(", "), [k], diff --git a/apps/desktop/src/hooks/useShortcutRegistry.ts b/apps/desktop/src/hooks/useShortcutRegistry.ts index 128f1bd3f0..e1352745b2 100644 --- a/apps/desktop/src/hooks/useShortcutRegistry.ts +++ b/apps/desktop/src/hooks/useShortcutRegistry.ts @@ -1,7 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { commands as shortcutCommands } from "@hypr/plugin-shortcut"; +import { + commands as shortcutCommands, + type ShortcutId, +} from "@hypr/plugin-shortcut"; export function useShortcutRegistry() { const { data: shortcuts } = useQuery({ @@ -11,14 +14,14 @@ export function useShortcutRegistry() { }); const keysMap = useMemo(() => { - if (!shortcuts) return new Map(); + if (!shortcuts) return new Map(); return new Map(shortcuts.map((s) => [s.id, s.keys])); }, [shortcuts]); return { shortcuts, keysMap }; } -export function useShortcutKeys(id: string): string { +export function useShortcutKeys(id: ShortcutId): string { const { keysMap } = useShortcutRegistry(); return keysMap.get(id) ?? ""; } diff --git a/plugins/shortcut/js/bindings.gen.ts b/plugins/shortcut/js/bindings.gen.ts index ee55e497f8..7f59aa666d 100644 --- a/plugins/shortcut/js/bindings.gen.ts +++ b/plugins/shortcut/js/bindings.gen.ts @@ -1,90 +1,87 @@ // @ts-nocheck -/** tauri-specta globals **/ -import { - Channel as TAURI_CHANNEL, - invoke as TAURI_INVOKE, -} from "@tauri-apps/api/core"; -import * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ + export const commands = { - async getAllShortcuts(): Promise { +async getAllShortcuts() : Promise { return await TAURI_INVOKE("plugin:shortcut|get_all_shortcuts"); - }, -}; +} +} /** user-defined events **/ + + /** user-defined constants **/ + + /** user-defined types **/ -export type ShortcutCategory = - | "Tabs" - | "Navigation" - | "Editor" - | "Search" - | "View"; -export type ShortcutDef = { - id: string; - keys: string; - category: ShortcutCategory; - description: string; - scope: ShortcutScope; -}; -export type ShortcutScope = "Global" | "Scoped"; +export type ShortcutCategory = "Tabs" | "Navigation" | "Editor" | "Search" | "View" +export type ShortcutDef = { id: ShortcutId; keys: string; category: ShortcutCategory; description: string; scope: ShortcutScope } +export type ShortcutId = "new_note" | "new_empty_tab" | "close_tab" | "select_tab_1" | "select_tab_2" | "select_tab_3" | "select_tab_4" | "select_tab_5" | "select_tab_6" | "select_tab_7" | "select_tab_8" | "select_tab_9" | "prev_tab" | "next_tab" | "restore_closed_tab" | "open_calendar" | "open_contacts" | "open_ai_settings" | "open_folders" | "open_search" | "new_note_and_listen" | "toggle_chat" | "open_settings" | "toggle_sidebar" | "focus_search" | "open_note_dialog" | "switch_to_enhanced" | "switch_to_raw" | "switch_to_transcript" | "prev_panel_tab" | "next_panel_tab" | "transcript_search" | "undo_delete" | "dismiss" | "play_pause_audio" +export type ShortcutScope = "Global" | "Scoped" + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; - - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/shortcut/src/registry.rs b/plugins/shortcut/src/registry.rs index a9a922435a..03add9439f 100644 --- a/plugins/shortcut/src/registry.rs +++ b/plugins/shortcut/src/registry.rs @@ -1,247 +1,247 @@ -use crate::types::{ShortcutCategory, ShortcutDef, ShortcutScope}; +use crate::types::{ShortcutCategory, ShortcutDef, ShortcutId, ShortcutScope}; pub fn all() -> Vec { vec![ ShortcutDef { - id: "new_note".into(), + id: ShortcutId::NewNote, keys: "mod+n".into(), category: ShortcutCategory::Tabs, description: "Create a new note".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "new_empty_tab".into(), + id: ShortcutId::NewEmptyTab, keys: "mod+t".into(), category: ShortcutCategory::Tabs, description: "Open a new empty tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "close_tab".into(), + id: ShortcutId::CloseTab, keys: "mod+w".into(), category: ShortcutCategory::Tabs, description: "Close current tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_1".into(), + id: ShortcutId::SelectTab1, keys: "mod+1".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 1".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_2".into(), + id: ShortcutId::SelectTab2, keys: "mod+2".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 2".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_3".into(), + id: ShortcutId::SelectTab3, keys: "mod+3".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 3".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_4".into(), + id: ShortcutId::SelectTab4, keys: "mod+4".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 4".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_5".into(), + id: ShortcutId::SelectTab5, keys: "mod+5".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 5".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_6".into(), + id: ShortcutId::SelectTab6, keys: "mod+6".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 6".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_7".into(), + id: ShortcutId::SelectTab7, keys: "mod+7".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 7".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_8".into(), + id: ShortcutId::SelectTab8, keys: "mod+8".into(), category: ShortcutCategory::Tabs, description: "Switch to tab 8".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "select_tab_9".into(), + id: ShortcutId::SelectTab9, keys: "mod+9".into(), category: ShortcutCategory::Tabs, description: "Switch to last tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "prev_tab".into(), + id: ShortcutId::PrevTab, keys: "mod+alt+left".into(), category: ShortcutCategory::Navigation, description: "Switch to previous tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "next_tab".into(), + id: ShortcutId::NextTab, keys: "mod+alt+right".into(), category: ShortcutCategory::Navigation, description: "Switch to next tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "restore_closed_tab".into(), + id: ShortcutId::RestoreClosedTab, keys: "mod+shift+t".into(), category: ShortcutCategory::Tabs, description: "Restore last closed tab".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_calendar".into(), + id: ShortcutId::OpenCalendar, keys: "mod+shift+c".into(), category: ShortcutCategory::Navigation, description: "Open calendar".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_contacts".into(), + id: ShortcutId::OpenContacts, keys: "mod+shift+o".into(), category: ShortcutCategory::Navigation, description: "Open contacts".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_ai_settings".into(), + id: ShortcutId::OpenAiSettings, keys: "mod+shift+comma".into(), category: ShortcutCategory::Navigation, description: "Open AI settings".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_folders".into(), + id: ShortcutId::OpenFolders, keys: "mod+shift+l".into(), category: ShortcutCategory::Navigation, description: "Open folders".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_search".into(), + id: ShortcutId::OpenSearch, keys: "mod+shift+f".into(), category: ShortcutCategory::Search, description: "Open advanced search".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "new_note_and_listen".into(), + id: ShortcutId::NewNoteAndListen, keys: "mod+shift+n".into(), category: ShortcutCategory::Tabs, description: "Create a new note and start listening".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "toggle_chat".into(), + id: ShortcutId::ToggleChat, keys: "mod+j".into(), category: ShortcutCategory::View, description: "Toggle chat panel".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "open_settings".into(), + id: ShortcutId::OpenSettings, keys: "mod+,".into(), category: ShortcutCategory::Navigation, description: "Open settings".into(), scope: ShortcutScope::Global, }, ShortcutDef { - id: "toggle_sidebar".into(), + id: ShortcutId::ToggleSidebar, keys: "mod+\\".into(), category: ShortcutCategory::View, description: "Toggle sidebar".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "focus_search".into(), + id: ShortcutId::FocusSearch, keys: "mod+k".into(), category: ShortcutCategory::Search, description: "Focus search input".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "open_note_dialog".into(), + id: ShortcutId::OpenNoteDialog, keys: "mod+o".into(), category: ShortcutCategory::Navigation, description: "Open note dialog".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "switch_to_enhanced".into(), + id: ShortcutId::SwitchToEnhanced, keys: "alt+s".into(), category: ShortcutCategory::Editor, description: "Switch to enhanced editor tab".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "switch_to_raw".into(), + id: ShortcutId::SwitchToRaw, keys: "alt+m".into(), category: ShortcutCategory::Editor, description: "Switch to raw editor tab".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "switch_to_transcript".into(), + id: ShortcutId::SwitchToTranscript, keys: "alt+t".into(), category: ShortcutCategory::Editor, description: "Switch to transcript tab".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "prev_panel_tab".into(), + id: ShortcutId::PrevPanelTab, keys: "ctrl+alt+left".into(), category: ShortcutCategory::Navigation, description: "Switch to previous panel tab".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "next_panel_tab".into(), + id: ShortcutId::NextPanelTab, keys: "ctrl+alt+right".into(), category: ShortcutCategory::Navigation, description: "Switch to next panel tab".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "transcript_search".into(), + id: ShortcutId::TranscriptSearch, keys: "mod+f".into(), category: ShortcutCategory::Search, description: "Search in transcript".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "undo_delete".into(), + id: ShortcutId::UndoDelete, keys: "mod+z".into(), category: ShortcutCategory::Tabs, description: "Undo delete".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "dismiss".into(), + id: ShortcutId::Dismiss, keys: "esc".into(), category: ShortcutCategory::View, description: "Dismiss / close".into(), scope: ShortcutScope::Scoped, }, ShortcutDef { - id: "play_pause_audio".into(), + id: ShortcutId::PlayPauseAudio, keys: "space".into(), category: ShortcutCategory::Editor, description: "Play or pause audio playback".into(), diff --git a/plugins/shortcut/src/types.rs b/plugins/shortcut/src/types.rs index f3ee29b928..4e7cfb2a0e 100644 --- a/plugins/shortcut/src/types.rs +++ b/plugins/shortcut/src/types.rs @@ -1,9 +1,49 @@ use serde::{Deserialize, Serialize}; use specta::Type; +#[derive(Serialize, Deserialize, Clone, Type)] +#[serde(rename_all = "snake_case")] +pub enum ShortcutId { + NewNote, + NewEmptyTab, + CloseTab, + SelectTab1, + SelectTab2, + SelectTab3, + SelectTab4, + SelectTab5, + SelectTab6, + SelectTab7, + SelectTab8, + SelectTab9, + PrevTab, + NextTab, + RestoreClosedTab, + OpenCalendar, + OpenContacts, + OpenAiSettings, + OpenFolders, + OpenSearch, + NewNoteAndListen, + ToggleChat, + OpenSettings, + ToggleSidebar, + FocusSearch, + OpenNoteDialog, + SwitchToEnhanced, + SwitchToRaw, + SwitchToTranscript, + PrevPanelTab, + NextPanelTab, + TranscriptSearch, + UndoDelete, + Dismiss, + PlayPauseAudio, +} + #[derive(Serialize, Deserialize, Clone, Type)] pub struct ShortcutDef { - pub id: String, + pub id: ShortcutId, pub keys: String, pub category: ShortcutCategory, pub description: String, From b1b07fb15aef6426643303033ef82956f17e1883 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:59:12 +0000 Subject: [PATCH 06/11] fix: update ContactsState to match main branch Co-Authored-By: yujonglee --- apps/desktop/src/hooks/useGlobalShortcuts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/hooks/useGlobalShortcuts.ts index 2ce4b117c0..c4363e4638 100644 --- a/apps/desktop/src/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/hooks/useGlobalShortcuts.ts @@ -169,7 +169,7 @@ export function useGlobalShortcuts() { () => openNew({ type: "contacts", - state: { selectedOrganization: null, selectedPerson: null }, + state: { selected: null }, }), hotkeysOptions, [openNew], From 7ab7d13b4c759c8ebe2ce1dc351d3828876cbb66 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:20:40 +0000 Subject: [PATCH 07/11] refactor: add useScopedShortcut wrapper hook and simplify call sites Co-Authored-By: yujonglee --- .../src/components/main/body/empty/index.tsx | 10 ++--- .../main/body/sessions/note-input/index.tsx | 39 ++++++++----------- .../note-input/transcript/search-context.tsx | 9 ++--- .../note-input/transcript/shared/index.tsx | 11 ++---- .../main/sidebar/toast/undo-delete-toast.tsx | 10 ++--- apps/desktop/src/contexts/search/ui.tsx | 7 +--- .../desktop/src/contexts/shell/leftsidebar.ts | 10 ++--- apps/desktop/src/hooks/useShortcutRegistry.ts | 12 ++++++ 8 files changed, 46 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/components/main/body/empty/index.tsx b/apps/desktop/src/components/main/body/empty/index.tsx index c408bedc34..470bfa5030 100644 --- a/apps/desktop/src/components/main/body/empty/index.tsx +++ b/apps/desktop/src/components/main/body/empty/index.tsx @@ -1,11 +1,10 @@ import { AppWindowIcon } from "lucide-react"; import { useCallback, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { Kbd } from "@hypr/ui/components/ui/kbd"; import { cn } from "@hypr/utils"; -import { useShortcutKeys } from "../../../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../../../hooks/useShortcutRegistry"; import { type Tab, useTabs } from "../../../../store/zustand/tabs"; import { useNewNote } from "../../shared"; import { StandardTabWrapper } from "../index"; @@ -78,15 +77,12 @@ function EmptyView() { [openCurrent], ); - const openNoteDialogKeys = useShortcutKeys("open_note_dialog"); - - useHotkeys( - openNoteDialogKeys, + useScopedShortcut( + "open_note_dialog", () => setOpenNoteDialogOpen(true), { preventDefault: true, enableOnFormTags: true, - enabled: !!openNoteDialogKeys, }, [setOpenNoteDialogOpen], ); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx index dd4f63d330..13ca0cb276 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/index.tsx @@ -8,7 +8,6 @@ import { useRef, useState, } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync"; import type { TiptapEditor } from "@hypr/tiptap/editor"; @@ -20,7 +19,7 @@ import { cn } from "@hypr/utils"; import { useListener } from "../../../../../contexts/listener"; import { useScrollPreservation } from "../../../../../hooks/useScrollPreservation"; -import { useShortcutKeys } from "../../../../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../../../../hooks/useShortcutRegistry"; import * as main from "../../../../../store/tinybase/store/main"; import { parseTranscriptWords, @@ -675,20 +674,14 @@ function useTabShortcuts({ currentTab: EditorView; handleTabChange: (view: EditorView) => void; }) { - const switchToEnhancedKeys = useShortcutKeys("switch_to_enhanced"); - const switchToRawKeys = useShortcutKeys("switch_to_raw"); - const switchToTranscriptKeys = useShortcutKeys("switch_to_transcript"); - const prevPanelTabKeys = useShortcutKeys("prev_panel_tab"); - const nextPanelTabKeys = useShortcutKeys("next_panel_tab"); - const scopedOptions = { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }; - useHotkeys( - switchToEnhancedKeys, + useScopedShortcut( + "switch_to_enhanced", () => { const enhancedTabs = editorTabs.filter((t) => t.type === "enhanced"); if (enhancedTabs.length === 0) return; @@ -703,36 +696,36 @@ function useTabShortcuts({ handleTabChange(enhancedTabs[0]); } }, - { ...scopedOptions, enabled: !!switchToEnhancedKeys }, + scopedOptions, [currentTab, editorTabs, handleTabChange], ); - useHotkeys( - switchToRawKeys, + useScopedShortcut( + "switch_to_raw", () => { const rawTab = editorTabs.find((t) => t.type === "raw"); if (rawTab && currentTab.type !== "raw") { handleTabChange(rawTab); } }, - { ...scopedOptions, enabled: !!switchToRawKeys }, + scopedOptions, [currentTab, editorTabs, handleTabChange], ); - useHotkeys( - switchToTranscriptKeys, + useScopedShortcut( + "switch_to_transcript", () => { const transcriptTab = editorTabs.find((t) => t.type === "transcript"); if (transcriptTab && currentTab.type !== "transcript") { handleTabChange(transcriptTab); } }, - { ...scopedOptions, enabled: !!switchToTranscriptKeys }, + scopedOptions, [currentTab, editorTabs, handleTabChange], ); - useHotkeys( - prevPanelTabKeys, + useScopedShortcut( + "prev_panel_tab", () => { const currentIndex = editorTabs.findIndex( (t) => @@ -745,12 +738,12 @@ function useTabShortcuts({ handleTabChange(editorTabs[currentIndex - 1]); } }, - { ...scopedOptions, enabled: !!prevPanelTabKeys }, + scopedOptions, [currentTab, editorTabs, handleTabChange], ); - useHotkeys( - nextPanelTabKeys, + useScopedShortcut( + "next_panel_tab", () => { const currentIndex = editorTabs.findIndex( (t) => @@ -763,7 +756,7 @@ function useTabShortcuts({ handleTabChange(editorTabs[currentIndex + 1]); } }, - { ...scopedOptions, enabled: !!nextPanelTabKeys }, + scopedOptions, [currentTab, editorTabs, handleTabChange], ); } diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx index 97e4196872..6d819467f5 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { useShortcutKeys } from "../../../../../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../../../../../hooks/useShortcutRegistry"; export interface SearchOptions { caseSensitive: boolean; @@ -246,10 +246,8 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { setShowReplace((prev) => !prev); }, []); - const transcriptSearchKeys = useShortcutKeys("transcript_search"); - - useHotkeys( - transcriptSearchKeys, + useScopedShortcut( + "transcript_search", (event) => { event.preventDefault(); setIsVisible((prev) => !prev); @@ -258,7 +256,6 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, - enabled: !!transcriptSearchKeys, }, [], ); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx index ca694a915c..5d9a3d53dc 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/index.tsx @@ -1,6 +1,5 @@ import { TriangleAlert } from "lucide-react"; import { type RefObject, useCallback, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import type { DegradedError } from "@hypr/plugin-listener"; import type { RuntimeSpeakerHint } from "@hypr/transcript"; @@ -8,7 +7,7 @@ import { cn } from "@hypr/utils"; import { useAudioPlayer } from "../../../../../../../contexts/audio-player/provider"; import { useListener } from "../../../../../../../contexts/listener"; -import { useShortcutKeys } from "../../../../../../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../../../../../../hooks/useShortcutRegistry"; import * as main from "../../../../../../../store/tinybase/store/main"; import { TranscriptEmptyState } from "../empty-state"; import { @@ -103,10 +102,8 @@ export function TranscriptContainer({ const currentMs = time.current * 1000; const isPlaying = playerState === "playing"; - const playPauseKeys = useShortcutKeys("play_pause_audio"); - - useHotkeys( - playPauseKeys, + useScopedShortcut( + "play_pause_audio", (e) => { e.preventDefault(); if (playerState === "playing") { @@ -117,7 +114,7 @@ export function TranscriptContainer({ start(); } }, - { enableOnFormTags: false, enabled: !!playPauseKeys }, + { enableOnFormTags: false }, ); usePlaybackAutoScroll(containerRef, currentMs, isPlaying); diff --git a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx index 464be6eb27..7f670fb34b 100644 --- a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx +++ b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx @@ -2,11 +2,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { useHotkeys } from "react-hotkeys-hook"; import { cn } from "@hypr/utils"; -import { useShortcutKeys } from "../../../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../../../hooks/useShortcutRegistry"; import { restoreSessionData } from "../../../../store/tinybase/store/deleteSession"; import * as main from "../../../../store/tinybase/store/main"; import { useTabs } from "../../../../store/zustand/tabs"; @@ -214,10 +213,8 @@ export function UndoDeleteKeyboardHandler() { return groups[groups.length - 1]; }, [groups]); - const undoDeleteKeys = useShortcutKeys("undo_delete"); - - useHotkeys( - undoDeleteKeys, + useScopedShortcut( + "undo_delete", () => { if (latestGroup) { restoreGroup(latestGroup); @@ -227,7 +224,6 @@ export function UndoDeleteKeyboardHandler() { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, - enabled: !!undoDeleteKeys, }, [latestGroup, restoreGroup], ); diff --git a/apps/desktop/src/contexts/search/ui.tsx b/apps/desktop/src/contexts/search/ui.tsx index f79d36019f..fa5553c580 100644 --- a/apps/desktop/src/contexts/search/ui.tsx +++ b/apps/desktop/src/contexts/search/ui.tsx @@ -12,7 +12,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; -import { useShortcutKeys } from "../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../hooks/useShortcutRegistry"; import type { SearchDocument, SearchEntityType, @@ -190,13 +190,10 @@ export function SearchUIProvider({ children }: { children: React.ReactNode }) { focusImplRef.current = impl; }, []); - const focusSearchKeys = useShortcutKeys("focus_search"); - - useHotkeys(focusSearchKeys, () => focus(), { + useScopedShortcut("focus_search", () => focus(), { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, - enabled: !!focusSearchKeys, }); useHotkeys( diff --git a/apps/desktop/src/contexts/shell/leftsidebar.ts b/apps/desktop/src/contexts/shell/leftsidebar.ts index 1b7c94ea1b..1025480491 100644 --- a/apps/desktop/src/contexts/shell/leftsidebar.ts +++ b/apps/desktop/src/contexts/shell/leftsidebar.ts @@ -1,7 +1,6 @@ import { useCallback, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { useShortcutKeys } from "../../hooks/useShortcutRegistry"; +import { useScopedShortcut } from "../../hooks/useShortcutRegistry"; export function useLeftSidebar() { const [expanded, setExpanded] = useState(true); @@ -15,16 +14,13 @@ export function useLeftSidebar() { setShowDevtool((prev) => !prev); }, []); - const toggleSidebarKeys = useShortcutKeys("toggle_sidebar"); - - useHotkeys( - toggleSidebarKeys, + useScopedShortcut( + "toggle_sidebar", toggleExpanded, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, - enabled: !!toggleSidebarKeys, }, [toggleExpanded], ); diff --git a/apps/desktop/src/hooks/useShortcutRegistry.ts b/apps/desktop/src/hooks/useShortcutRegistry.ts index e1352745b2..9ee4b994bd 100644 --- a/apps/desktop/src/hooks/useShortcutRegistry.ts +++ b/apps/desktop/src/hooks/useShortcutRegistry.ts @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import type { DependencyList } from "react"; import { useMemo } from "react"; +import { type Options, useHotkeys } from "react-hotkeys-hook"; import { commands as shortcutCommands, @@ -25,3 +27,13 @@ export function useShortcutKeys(id: ShortcutId): string { const { keysMap } = useShortcutRegistry(); return keysMap.get(id) ?? ""; } + +export function useScopedShortcut( + id: ShortcutId, + handler: (e: KeyboardEvent) => void, + options?: Omit, + deps?: DependencyList, +): void { + const keys = useShortcutKeys(id); + useHotkeys(keys, handler, { ...options, enabled: !!keys }, deps ?? []); +} From 831f391f5ce4462eb227e892e68f20029301f590 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:37:52 +0000 Subject: [PATCH 08/11] feat: generate keyboard shortcuts doc deterministically from registry Add askama template-based doc generation for the keyboard shortcuts MDX. Running 'cargo test -p tauri-plugin-shortcut' (codegen) now generates both TS bindings and the docs page from the Rust shortcut registry. - Add doc.rs with key-to-kbd formatting and section builder - Add askama template for keyboard-shortcuts.mdx - Add export_docs test that renders template to MDX output - Reorder ShortcutCategory variants to control doc section order - Add display_name() to ShortcutCategory for doc section headers Co-Authored-By: yujonglee --- Cargo.lock | 1 + .../content/docs/faq/6.keyboard-shortcuts.mdx | 97 +++++++------- plugins/shortcut/Cargo.toml | 1 + plugins/shortcut/askama.toml | 2 + .../assets/keyboard-shortcuts.mdx.jinja | 15 +++ plugins/shortcut/js/bindings.gen.ts | 2 +- plugins/shortcut/src/doc.rs | 123 ++++++++++++++++++ plugins/shortcut/src/lib.rs | 19 +++ plugins/shortcut/src/types.rs | 20 ++- 9 files changed, 228 insertions(+), 52 deletions(-) create mode 100644 plugins/shortcut/askama.toml create mode 100644 plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja create mode 100644 plugins/shortcut/src/doc.rs diff --git a/Cargo.lock b/Cargo.lock index aec0115e49..de7af54379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19408,6 +19408,7 @@ dependencies = [ name = "tauri-plugin-shortcut" version = "0.1.0" dependencies = [ + "askama", "serde", "specta", "specta-typescript", diff --git a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx index 102af15c11..cdd83e5145 100644 --- a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx +++ b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx @@ -4,64 +4,67 @@ section: "FAQ" description: "Complete list of keyboard shortcuts in Char." --- + ## Navigation -| Shortcut | Action | -| ------------------------------------------------------------ | ----------------------------------- | -| + 1, 2, 3, ... | Switch to specific outer tab | -| + + / | Navigate to next/previous outer tab | -| + + / | Navigate to next/previous inner tab | -| + S | Switch to summary tab | -| + M | Switch to memos tab | -| + T | Switch to transcript tab | +| Shortcut | Action | +| -------- | ------ | +| + + | Switch to previous tab | +| + + | Switch to next tab | +| + + C | Open calendar | +| + + O | Open contacts | +| + + , | Open AI settings | +| + + L | Open folders | +| + , | Open settings | +| + O | Open note dialog | +| + + | Switch to previous panel tab | +| + + | Switch to next panel tab | -## Sidebar & Panels -| Shortcut | Action | -| ---------------------------- | ------------------- | -| + \\ | Toggle Left Sidebar | -| + J | Toggle AI assistant | +## Sidebar & Panels -## Notes & Tabs +| Shortcut | Action | +| -------- | ------ | +| + J | Toggle chat panel | +| + \ | Toggle sidebar | +| Esc | Dismiss / close | -| Shortcut | Action | -| ------------------------------------------ | ------------------ | -| + N | Create new note | -| + + N | Create new note and start listening | -| + O | Open note | -| + T | Open new tab | -| + W | Close current tab | -| + + T | Restore closed tab | -## Quick Access +## Notes & Tabs -| Shortcut | Action | -| ------------------------------------------ | ------------------------------ | -| + K | Focus Searchbar | -| + F | Search inside note or editor | -| + , | Open App Settings | -| + + , | Open AI Settings | -| + + C | Open Calendar | -| + + D | Open Daily Notes | -| + + F | Open Advanced search | -| + + L | Open Folders | -| + + O | Open Contacts | +| Shortcut | Action | +| -------- | ------ | +| + N | Create a new note | +| + T | Open a new empty tab | +| + W | Close current tab | +| + 1 | Switch to tab 1 | +| + 2 | Switch to tab 2 | +| + 3 | Switch to tab 3 | +| + 4 | Switch to tab 4 | +| + 5 | Switch to tab 5 | +| + 6 | Switch to tab 6 | +| + 7 | Switch to tab 7 | +| + 8 | Switch to tab 8 | +| + 9 | Switch to last tab | +| + + T | Restore last closed tab | +| + + N | Create a new note and start listening | +| + Z | Undo delete | -## Audio Playback -| Shortcut | Action | -| --------------- | ------------------------------------------- | -| Space | Play/pause audio in transcript view | +## Quick Access -## Editing +| Shortcut | Action | +| -------- | ------ | +| + + F | Open advanced search | +| + K | Focus search input | +| + F | Search in transcript | -| Shortcut | Action | -| --------------------------- | ------------------------------------------- | -| + Z | Undo (also restores recently deleted notes) | -## Application +## Editor -| Shortcut | Action | -| ------------------------------------------------- | ---------------------------------------------- | -| + Q (hold) | Show quit overlay with gauge visual | -| + + Q | Quit Completely | +| Shortcut | Action | +| -------- | ------ | +| + S | Switch to enhanced editor tab | +| + M | Switch to raw editor tab | +| + T | Switch to transcript tab | +| Space | Play or pause audio playback | diff --git a/plugins/shortcut/Cargo.toml b/plugins/shortcut/Cargo.toml index 36c32b406c..8566d345ac 100644 --- a/plugins/shortcut/Cargo.toml +++ b/plugins/shortcut/Cargo.toml @@ -11,6 +11,7 @@ description = "" tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] +askama = { workspace = true } specta-typescript = { workspace = true } [dependencies] diff --git a/plugins/shortcut/askama.toml b/plugins/shortcut/askama.toml new file mode 100644 index 0000000000..291eba2963 --- /dev/null +++ b/plugins/shortcut/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["assets"] diff --git a/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja b/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja new file mode 100644 index 0000000000..0805722be3 --- /dev/null +++ b/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja @@ -0,0 +1,15 @@ +--- +title: "Keyboard Shortcuts" +section: "FAQ" +description: "Complete list of keyboard shortcuts in Char." +--- +{% for section in sections %} + +## {{ section.title }} + +| Shortcut | Action | +| -------- | ------ | +{% for shortcut in section.shortcuts -%} +| {{ shortcut.keys_display }} | {{ shortcut.description }} | +{% endfor -%} +{% endfor -%} diff --git a/plugins/shortcut/js/bindings.gen.ts b/plugins/shortcut/js/bindings.gen.ts index 7f59aa666d..0692315013 100644 --- a/plugins/shortcut/js/bindings.gen.ts +++ b/plugins/shortcut/js/bindings.gen.ts @@ -21,7 +21,7 @@ async getAllShortcuts() : Promise { /** user-defined types **/ -export type ShortcutCategory = "Tabs" | "Navigation" | "Editor" | "Search" | "View" +export type ShortcutCategory = "Navigation" | "View" | "Tabs" | "Search" | "Editor" export type ShortcutDef = { id: ShortcutId; keys: string; category: ShortcutCategory; description: string; scope: ShortcutScope } export type ShortcutId = "new_note" | "new_empty_tab" | "close_tab" | "select_tab_1" | "select_tab_2" | "select_tab_3" | "select_tab_4" | "select_tab_5" | "select_tab_6" | "select_tab_7" | "select_tab_8" | "select_tab_9" | "prev_tab" | "next_tab" | "restore_closed_tab" | "open_calendar" | "open_contacts" | "open_ai_settings" | "open_folders" | "open_search" | "new_note_and_listen" | "toggle_chat" | "open_settings" | "toggle_sidebar" | "focus_search" | "open_note_dialog" | "switch_to_enhanced" | "switch_to_raw" | "switch_to_transcript" | "prev_panel_tab" | "next_panel_tab" | "transcript_search" | "undo_delete" | "dismiss" | "play_pause_audio" export type ShortcutScope = "Global" | "Scoped" diff --git a/plugins/shortcut/src/doc.rs b/plugins/shortcut/src/doc.rs new file mode 100644 index 0000000000..c8f4d940d3 --- /dev/null +++ b/plugins/shortcut/src/doc.rs @@ -0,0 +1,123 @@ +use crate::registry; +use crate::types::ShortcutCategory; + +pub struct DocSection { + pub title: String, + pub shortcuts: Vec, +} + +pub struct DocShortcutEntry { + pub keys_display: String, + pub description: String, +} + +fn format_key_part(part: &str) -> String { + match part { + "mod" => "\u{2318}".to_string(), + "shift" => "\u{21e7}".to_string(), + "alt" => "\u{2325}".to_string(), + "ctrl" => "\u{2303}".to_string(), + "left" => "\u{2190}".to_string(), + "right" => "\u{2192}".to_string(), + "space" => "Space".to_string(), + "esc" => "Esc".to_string(), + "comma" => ",".to_string(), + "\\" => "\\".to_string(), + other => { + let display = other.to_uppercase(); + format!("{}", display) + } + } +} + +pub fn format_keys_as_kbd(keys: &str) -> String { + keys.split('+') + .map(|part| format_key_part(part.trim())) + .collect::>() + .join(" + ") +} + +pub fn build_sections() -> Vec { + let all = registry::all(); + + let mut categories: Vec = all.iter().map(|s| s.category.clone()).collect(); + categories.sort(); + categories.dedup(); + + categories + .into_iter() + .map(|cat| { + let shortcuts = all + .iter() + .filter(|s| s.category == cat) + .map(|s| DocShortcutEntry { + keys_display: format_keys_as_kbd(&s.keys), + description: s.description.clone(), + }) + .collect(); + + DocSection { + title: cat.display_name().to_string(), + shortcuts, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_keys_mod_n() { + assert_eq!( + format_keys_as_kbd("mod+n"), + "\u{2318} + N" + ); + } + + #[test] + fn test_format_keys_mod_shift_n() { + assert_eq!( + format_keys_as_kbd("mod+shift+n"), + "\u{2318} + \u{21e7} + N" + ); + } + + #[test] + fn test_format_keys_alt_s() { + assert_eq!( + format_keys_as_kbd("alt+s"), + "\u{2325} + S" + ); + } + + #[test] + fn test_format_keys_ctrl_alt_left() { + assert_eq!( + format_keys_as_kbd("ctrl+alt+left"), + "\u{2303} + \u{2325} + \u{2190}" + ); + } + + #[test] + fn test_format_keys_space() { + assert_eq!(format_keys_as_kbd("space"), "Space"); + } + + #[test] + fn test_format_keys_mod_backslash() { + assert_eq!( + format_keys_as_kbd("mod+\\"), + "\u{2318} + \\" + ); + } + + #[test] + fn test_format_keys_mod_comma() { + assert_eq!( + format_keys_as_kbd("mod+comma"), + "\u{2318} + ," + ); + } +} diff --git a/plugins/shortcut/src/lib.rs b/plugins/shortcut/src/lib.rs index cedc5dc2df..b2d31df136 100644 --- a/plugins/shortcut/src/lib.rs +++ b/plugins/shortcut/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +pub mod doc; pub mod registry; pub mod types; @@ -40,4 +41,22 @@ mod test { let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); } + + #[test] + fn export_docs() { + const OUTPUT_FILE: &str = "../../apps/web/content/docs/faq/6.keyboard-shortcuts.mdx"; + + #[derive(askama::Template)] + #[template(path = "keyboard-shortcuts.mdx.jinja", escape = "none")] + struct KeyboardShortcutsDoc { + sections: Vec, + } + + let doc = KeyboardShortcutsDoc { + sections: doc::build_sections(), + }; + + let rendered = askama::Template::render(&doc).unwrap(); + std::fs::write(OUTPUT_FILE, rendered).unwrap(); + } } diff --git a/plugins/shortcut/src/types.rs b/plugins/shortcut/src/types.rs index 4e7cfb2a0e..705fa85a75 100644 --- a/plugins/shortcut/src/types.rs +++ b/plugins/shortcut/src/types.rs @@ -50,13 +50,25 @@ pub struct ShortcutDef { pub scope: ShortcutScope, } -#[derive(Serialize, Deserialize, Clone, Type)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Type)] pub enum ShortcutCategory { - Tabs, Navigation, - Editor, - Search, View, + Tabs, + Search, + Editor, +} + +impl ShortcutCategory { + pub fn display_name(&self) -> &'static str { + match self { + ShortcutCategory::Navigation => "Navigation", + ShortcutCategory::View => "Sidebar & Panels", + ShortcutCategory::Tabs => "Notes & Tabs", + ShortcutCategory::Search => "Quick Access", + ShortcutCategory::Editor => "Editor", + } + } } #[derive(Serialize, Deserialize, Clone, Type)] From d7383e31145424297c0f6b795c031ca4145513f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:42:29 +0000 Subject: [PATCH 09/11] feat: add FindReplace shortcut to registry and regenerate docs Co-Authored-By: yujonglee --- apps/web/content/docs/faq/6.keyboard-shortcuts.mdx | 1 + plugins/shortcut/js/bindings.gen.ts | 2 +- plugins/shortcut/src/registry.rs | 7 +++++++ plugins/shortcut/src/types.rs | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx index cdd83e5145..983087bbe8 100644 --- a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx +++ b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx @@ -58,6 +58,7 @@ description: "Complete list of keyboard shortcuts in Char." | + + F | Open advanced search | | + K | Focus search input | | + F | Search in transcript | +| + H | Find and replace in note | ## Editor diff --git a/plugins/shortcut/js/bindings.gen.ts b/plugins/shortcut/js/bindings.gen.ts index 0692315013..f31580e269 100644 --- a/plugins/shortcut/js/bindings.gen.ts +++ b/plugins/shortcut/js/bindings.gen.ts @@ -23,7 +23,7 @@ async getAllShortcuts() : Promise { export type ShortcutCategory = "Navigation" | "View" | "Tabs" | "Search" | "Editor" export type ShortcutDef = { id: ShortcutId; keys: string; category: ShortcutCategory; description: string; scope: ShortcutScope } -export type ShortcutId = "new_note" | "new_empty_tab" | "close_tab" | "select_tab_1" | "select_tab_2" | "select_tab_3" | "select_tab_4" | "select_tab_5" | "select_tab_6" | "select_tab_7" | "select_tab_8" | "select_tab_9" | "prev_tab" | "next_tab" | "restore_closed_tab" | "open_calendar" | "open_contacts" | "open_ai_settings" | "open_folders" | "open_search" | "new_note_and_listen" | "toggle_chat" | "open_settings" | "toggle_sidebar" | "focus_search" | "open_note_dialog" | "switch_to_enhanced" | "switch_to_raw" | "switch_to_transcript" | "prev_panel_tab" | "next_panel_tab" | "transcript_search" | "undo_delete" | "dismiss" | "play_pause_audio" +export type ShortcutId = "new_note" | "new_empty_tab" | "close_tab" | "select_tab_1" | "select_tab_2" | "select_tab_3" | "select_tab_4" | "select_tab_5" | "select_tab_6" | "select_tab_7" | "select_tab_8" | "select_tab_9" | "prev_tab" | "next_tab" | "restore_closed_tab" | "open_calendar" | "open_contacts" | "open_ai_settings" | "open_folders" | "open_search" | "new_note_and_listen" | "toggle_chat" | "open_settings" | "toggle_sidebar" | "focus_search" | "open_note_dialog" | "switch_to_enhanced" | "switch_to_raw" | "switch_to_transcript" | "prev_panel_tab" | "next_panel_tab" | "transcript_search" | "find_replace" | "undo_delete" | "dismiss" | "play_pause_audio" export type ShortcutScope = "Global" | "Scoped" /** tauri-specta globals **/ diff --git a/plugins/shortcut/src/registry.rs b/plugins/shortcut/src/registry.rs index 03add9439f..bab34a21dd 100644 --- a/plugins/shortcut/src/registry.rs +++ b/plugins/shortcut/src/registry.rs @@ -226,6 +226,13 @@ pub fn all() -> Vec { description: "Search in transcript".into(), scope: ShortcutScope::Scoped, }, + ShortcutDef { + id: ShortcutId::FindReplace, + keys: "mod+h".into(), + category: ShortcutCategory::Search, + description: "Find and replace in note".into(), + scope: ShortcutScope::Scoped, + }, ShortcutDef { id: ShortcutId::UndoDelete, keys: "mod+z".into(), diff --git a/plugins/shortcut/src/types.rs b/plugins/shortcut/src/types.rs index 705fa85a75..42f09a8e10 100644 --- a/plugins/shortcut/src/types.rs +++ b/plugins/shortcut/src/types.rs @@ -36,6 +36,7 @@ pub enum ShortcutId { PrevPanelTab, NextPanelTab, TranscriptSearch, + FindReplace, UndoDelete, Dismiss, PlayPauseAudio, From d2bf99c6e5135919ff83abb6b85e0846bee8f660 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:59:12 +0000 Subject: [PATCH 10/11] fix: escape backslash in MDX and make template dprint-compatible Co-Authored-By: yujonglee --- .../content/docs/faq/6.keyboard-shortcuts.mdx | 13 +++++----- .../assets/keyboard-shortcuts.mdx.jinja | 9 +++---- plugins/shortcut/src/doc.rs | 26 +++++++++++++------ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx index 983087bbe8..21ae40874a 100644 --- a/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx +++ b/apps/web/content/docs/faq/6.keyboard-shortcuts.mdx @@ -4,11 +4,10 @@ section: "FAQ" description: "Complete list of keyboard shortcuts in Char." --- - ## Navigation | Shortcut | Action | -| -------- | ------ | +| --- | --- | | + + | Switch to previous tab | | + + | Switch to next tab | | + + C | Open calendar | @@ -24,16 +23,16 @@ description: "Complete list of keyboard shortcuts in Char." ## Sidebar & Panels | Shortcut | Action | -| -------- | ------ | +| --- | --- | | + J | Toggle chat panel | -| + \ | Toggle sidebar | +| + \\ | Toggle sidebar | | Esc | Dismiss / close | ## Notes & Tabs | Shortcut | Action | -| -------- | ------ | +| --- | --- | | + N | Create a new note | | + T | Open a new empty tab | | + W | Close current tab | @@ -54,7 +53,7 @@ description: "Complete list of keyboard shortcuts in Char." ## Quick Access | Shortcut | Action | -| -------- | ------ | +| --- | --- | | + + F | Open advanced search | | + K | Focus search input | | + F | Search in transcript | @@ -64,7 +63,7 @@ description: "Complete list of keyboard shortcuts in Char." ## Editor | Shortcut | Action | -| -------- | ------ | +| --- | --- | | + S | Switch to enhanced editor tab | | + M | Switch to raw editor tab | | + T | Switch to transcript tab | diff --git a/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja b/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja index 0805722be3..e41c34254f 100644 --- a/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja +++ b/plugins/shortcut/assets/keyboard-shortcuts.mdx.jinja @@ -3,13 +3,10 @@ title: "Keyboard Shortcuts" section: "FAQ" description: "Complete list of keyboard shortcuts in Char." --- -{% for section in sections %} + +{%- for section in sections %} ## {{ section.title }} -| Shortcut | Action | -| -------- | ------ | -{% for shortcut in section.shortcuts -%} -| {{ shortcut.keys_display }} | {{ shortcut.description }} | -{% endfor -%} +{{ section.table }} {% endfor -%} diff --git a/plugins/shortcut/src/doc.rs b/plugins/shortcut/src/doc.rs index c8f4d940d3..28384d9b77 100644 --- a/plugins/shortcut/src/doc.rs +++ b/plugins/shortcut/src/doc.rs @@ -3,12 +3,12 @@ use crate::types::ShortcutCategory; pub struct DocSection { pub title: String, - pub shortcuts: Vec, + pub table: String, } -pub struct DocShortcutEntry { - pub keys_display: String, - pub description: String, +struct DocShortcutEntry { + keys_display: String, + description: String, } fn format_key_part(part: &str) -> String { @@ -22,7 +22,7 @@ fn format_key_part(part: &str) -> String { "space" => "Space".to_string(), "esc" => "Esc".to_string(), "comma" => ",".to_string(), - "\\" => "\\".to_string(), + "\\" => "\\\\".to_string(), other => { let display = other.to_uppercase(); format!("{}", display) @@ -37,6 +37,16 @@ pub fn format_keys_as_kbd(keys: &str) -> String { .join(" + ") } +fn render_table(entries: &[DocShortcutEntry]) -> String { + let mut lines = Vec::new(); + lines.push("| Shortcut | Action |".to_string()); + lines.push("| --- | --- |".to_string()); + for entry in entries { + lines.push(format!("| {} | {} |", entry.keys_display, entry.description)); + } + lines.join("\n") +} + pub fn build_sections() -> Vec { let all = registry::all(); @@ -47,7 +57,7 @@ pub fn build_sections() -> Vec { categories .into_iter() .map(|cat| { - let shortcuts = all + let shortcuts: Vec = all .iter() .filter(|s| s.category == cat) .map(|s| DocShortcutEntry { @@ -58,7 +68,7 @@ pub fn build_sections() -> Vec { DocSection { title: cat.display_name().to_string(), - shortcuts, + table: render_table(&shortcuts), } }) .collect() @@ -109,7 +119,7 @@ mod tests { fn test_format_keys_mod_backslash() { assert_eq!( format_keys_as_kbd("mod+\\"), - "\u{2318} + \\" + "\u{2318} + \\\\" ); } From a4cc0668695f63e421f35fdbb069dfc442c70f0a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:23:43 +0000 Subject: [PATCH 11/11] style: apply rustfmt formatting to doc.rs Co-Authored-By: yujonglee --- plugins/shortcut/src/doc.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/shortcut/src/doc.rs b/plugins/shortcut/src/doc.rs index 28384d9b77..567fdd0274 100644 --- a/plugins/shortcut/src/doc.rs +++ b/plugins/shortcut/src/doc.rs @@ -42,7 +42,10 @@ fn render_table(entries: &[DocShortcutEntry]) -> String { lines.push("| Shortcut | Action |".to_string()); lines.push("| --- | --- |".to_string()); for entry in entries { - lines.push(format!("| {} | {} |", entry.keys_display, entry.description)); + lines.push(format!( + "| {} | {} |", + entry.keys_display, entry.description + )); } lines.join("\n") }