diff --git a/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts b/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts new file mode 100644 index 00000000000..350b152be03 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts @@ -0,0 +1,133 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { type BrowserWindow, dialog } from "electron"; +import { appState } from "main/lib/app-state"; +import { + buildHotkeysStateFromExport, + createHotkeysExport, + getCurrentPlatform, + getHotkeysSummary, + type HotkeysExportFile, + type HotkeysState, + normalizeBindingsWithDefaults, +} from "shared/hotkeys"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const hotkeysExportSchema = z.object({ + schemaVersion: z.number(), + exportedAt: z.string(), + app: z.string(), + hotkeys: z + .object({ + darwin: z.record(z.string(), z.string().nullable()).optional(), + win32: z.record(z.string(), z.string().nullable()).optional(), + linux: z.record(z.string(), z.string().nullable()).optional(), + }) + .optional(), +}); + +export type HotkeysImportResult = + | { canceled: true } + | { + canceled: false; + path: string; + state: HotkeysState; + summary: { assigned: number; disabled: number }; + raw: HotkeysExportFile; + } + | { canceled: false; error: string }; + +type HotkeysExportResult = + | { canceled: true } + | { canceled: false; path: string } + | { canceled: false; error: string }; + +export const createHotkeysRouter = (getWindow: () => BrowserWindow | null) => { + return router({ + export: publicProcedure.mutation(async (): Promise => { + const window = getWindow(); + if (!window) { + return { canceled: false, error: "No window available" }; + } + + const result = await dialog.showSaveDialog(window, { + title: "Export Keyboard Shortcuts", + defaultPath: "superset-hotkeys.json", + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const exportFile = createHotkeysExport(appState.data.hotkeysState); + try { + await writeFile( + result.filePath, + JSON.stringify(exportFile, null, 2), + "utf-8", + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to write file"; + return { canceled: false, error: message }; + } + + return { canceled: false, path: result.filePath }; + }), + + import: publicProcedure.mutation(async (): Promise => { + const window = getWindow(); + if (!window) { + return { canceled: false, error: "No window available" }; + } + + const result = await dialog.showOpenDialog(window, { + title: "Import Keyboard Shortcuts", + properties: ["openFile"], + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true }; + } + + const filePath = result.filePaths[0]; + + try { + const raw = await readFile(filePath, "utf-8"); + const parsed = hotkeysExportSchema.parse(JSON.parse(raw)); + const exportFile: HotkeysExportFile = { + schemaVersion: parsed.schemaVersion, + exportedAt: parsed.exportedAt, + app: parsed.app, + hotkeys: { + darwin: parsed.hotkeys?.darwin ?? {}, + win32: parsed.hotkeys?.win32 ?? {}, + linux: parsed.hotkeys?.linux ?? {}, + }, + }; + + const state = buildHotkeysStateFromExport(exportFile); + const platform = getCurrentPlatform(); + const bindings = normalizeBindingsWithDefaults( + exportFile.hotkeys?.[platform] ?? {}, + platform, + ); + const summary = getHotkeysSummary(bindings); + + return { + canceled: false, + path: filePath, + state, + summary, + raw: exportFile, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid hotkeys file"; + return { canceled: false, error: message }; + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 7cc2330e3d9..9fd7455f7c8 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -6,6 +6,7 @@ import { createAutoUpdateRouter } from "./auto-update"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; +import { createHotkeysRouter } from "./hotkeys"; import { createMenuRouter } from "./menu"; import { createNotificationsRouter } from "./notifications"; import { createPortsRouter } from "./ports"; @@ -33,6 +34,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { notifications: createNotificationsRouter(), ports: createPortsRouter(), menu: createMenuRouter(), + hotkeys: createHotkeysRouter(getWindow), external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index d1dda4cfcf5..0d2b8e87f55 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -1,5 +1,12 @@ +import { observable } from "@trpc/server/observable"; import { appState } from "main/lib/app-state"; import type { TabsState, ThemeState } from "main/lib/app-state/schemas"; +import { hotkeysEmitter } from "main/lib/hotkeys-events"; +import { + buildOverridesFromBindings, + HOTKEYS_STATE_VERSION, + type HotkeysState, +} from "shared/hotkeys"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -156,6 +163,15 @@ const themeStateSchema = z.object({ customThemes: z.array(themeSchema), }); +const hotkeysStateSchema = z.object({ + version: z.number(), + byPlatform: z.object({ + darwin: z.record(z.string(), z.string().nullable()).default({}), + win32: z.record(z.string(), z.string().nullable()).default({}), + linux: z.record(z.string(), z.string().nullable()).default({}), + }), +}); + /** * UI State router - manages tabs and theme persistence via lowdb */ @@ -190,5 +206,59 @@ export const createUiStateRouter = () => { return { success: true }; }), }), + + // Hotkeys state procedures + hotkeys: router({ + get: publicProcedure.query((): HotkeysState => { + return appState.data.hotkeysState; + }), + + set: publicProcedure + .input(hotkeysStateSchema) + .mutation(async ({ input }) => { + const version = + input.version === HOTKEYS_STATE_VERSION + ? input.version + : HOTKEYS_STATE_VERSION; + + const normalized: HotkeysState = { + version, + byPlatform: { + darwin: buildOverridesFromBindings( + input.byPlatform.darwin ?? {}, + "darwin", + ), + win32: buildOverridesFromBindings( + input.byPlatform.win32 ?? {}, + "win32", + ), + linux: buildOverridesFromBindings( + input.byPlatform.linux ?? {}, + "linux", + ), + }, + }; + + appState.data.hotkeysState = normalized; + await appState.write(); + hotkeysEmitter.emit("change", { + version: normalized.version, + updatedAt: new Date().toISOString(), + }); + return { success: true }; + }), + + subscribe: publicProcedure.subscription(() => { + return observable<{ version: number; updatedAt: string }>((emit) => { + const onChange = (data: { version: number; updatedAt: string }) => { + emit.next(data); + }; + hotkeysEmitter.on("change", onChange); + return () => { + hotkeysEmitter.off("change", onChange); + }; + }); + }), + }), }); }; diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 0940a7be3f1..00e9fe790f5 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -22,6 +22,14 @@ function ensureValidShape(data: Partial): AppState { ...defaultAppState.themeState, ...(data.themeState ?? {}), }, + hotkeysState: { + ...defaultAppState.hotkeysState, + ...(data.hotkeysState ?? {}), + byPlatform: { + ...defaultAppState.hotkeysState.byPlatform, + ...(data.hotkeysState?.byPlatform ?? {}), + }, + }, }; } diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts index 90c3e7138b4..e93767a761d 100644 --- a/apps/desktop/src/main/lib/app-state/schemas.ts +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -1,6 +1,7 @@ /** * UI state schemas (persisted from renderer zustand stores) */ +import { createDefaultHotkeysState, type HotkeysState } from "shared/hotkeys"; import type { BaseTabsState } from "shared/tabs-types"; import type { Theme } from "shared/themes"; @@ -15,6 +16,7 @@ export interface ThemeState { export interface AppState { tabsState: BaseTabsState; themeState: ThemeState; + hotkeysState: HotkeysState; } export const defaultAppState: AppState = { @@ -29,4 +31,5 @@ export const defaultAppState: AppState = { activeThemeId: "dark", customThemes: [], }, + hotkeysState: createDefaultHotkeysState(), }; diff --git a/apps/desktop/src/main/lib/hotkeys-events.ts b/apps/desktop/src/main/lib/hotkeys-events.ts new file mode 100644 index 00000000000..bf34063b195 --- /dev/null +++ b/apps/desktop/src/main/lib/hotkeys-events.ts @@ -0,0 +1,8 @@ +import { EventEmitter } from "node:events"; + +export interface HotkeysStateChangedEvent { + version: number; + updatedAt: string; +} + +export const hotkeysEmitter = new EventEmitter(); diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 8c7ccd1eb4a..3f08aac4455 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,6 +1,14 @@ import { COMPANY } from "@superset/shared/constants"; import { app, Menu, shell } from "electron"; import { env } from "main/env.main"; +import { appState } from "main/lib/app-state"; +import { hotkeysEmitter } from "main/lib/hotkeys-events"; +import { + getCurrentPlatform, + getEffectiveHotkey, + type HotkeyId, + toElectronAccelerator, +} from "shared/hotkeys"; import { checkForUpdatesInteractive, simulateDownloading, @@ -9,7 +17,28 @@ import { } from "./auto-updater"; import { menuEmitter } from "./menu-events"; +let isHotkeyListenerRegistered = false; + +function getMenuAccelerator(id: HotkeyId): string | undefined { + const platform = getCurrentPlatform(); + const overrides = appState.data.hotkeysState.byPlatform[platform]; + const keys = getEffectiveHotkey(id, overrides, platform); + const accelerator = toElectronAccelerator(keys, platform); + return accelerator ?? undefined; +} + +export function registerMenuHotkeyUpdates() { + if (isHotkeyListenerRegistered) return; + isHotkeyListenerRegistered = true; + hotkeysEmitter.on("change", () => { + createApplicationMenu(); + }); +} + export function createApplicationMenu() { + const closeAccelerator = getMenuAccelerator("CLOSE_WINDOW"); + const showHotkeysAccelerator = getMenuAccelerator("SHOW_HOTKEYS"); + const template: Electron.MenuItemConstructorOptions[] = [ { label: "Edit", @@ -43,7 +72,7 @@ export function createApplicationMenu() { { role: "minimize" }, { role: "zoom" }, { type: "separator" }, - { role: "close", accelerator: "CmdOrCtrl+Shift+W" }, + { role: "close", accelerator: closeAccelerator }, ], }, { @@ -70,6 +99,7 @@ export function createApplicationMenu() { { type: "separator" }, { label: "Keyboard Shortcuts", + accelerator: showHotkeysAccelerator, click: () => { menuEmitter.emit("open-settings", "keyboard"); }, diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index c49659d112b..fb29400b1a9 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -10,7 +10,7 @@ import { NOTIFICATION_EVENTS, PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; -import { createApplicationMenu } from "../lib/menu"; +import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { type AgentCompleteEvent, @@ -54,6 +54,7 @@ export async function MainWindow() { }); createApplicationMenu(); + registerMenuHotkeyUpdates(); currentWindow = window; diff --git a/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx b/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx new file mode 100644 index 00000000000..b015a0f3060 --- /dev/null +++ b/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx @@ -0,0 +1,88 @@ +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import type { ReactNode } from "react"; +import { + useEffectiveHotkeysMap, + useHotkeysStore, +} from "renderer/stores/hotkeys"; +import { formatHotkeyDisplay, type HotkeyId } from "shared/hotkeys"; + +export interface HotkeyTooltipContentItem { + label: string; + id: HotkeyId; +} + +interface HotkeyTooltipContentProps { + label: string; + hotkeyId?: HotkeyId; + items?: HotkeyTooltipContentItem[]; + showUnassigned?: boolean; + unassignedPlaceholder?: ReactNode; +} + +function isUnassigned(display: string[]): boolean { + return display.length === 1 && display[0] === "Unassigned"; +} + +export function HotkeyTooltipContent({ + label, + hotkeyId, + items, + showUnassigned = false, + unassignedPlaceholder = null, +}: HotkeyTooltipContentProps) { + const platform = useHotkeysStore((state) => state.platform); + const effective = useEffectiveHotkeysMap(); + + const getDisplay = (id: HotkeyId): string[] => { + const keys = effective[id] ?? null; + return formatHotkeyDisplay(keys, platform); + }; + + const renderShortcut = (id?: HotkeyId): ReactNode => { + if (!id) return null; + const display = getDisplay(id); + if (isUnassigned(display)) { + return showUnassigned ? unassignedPlaceholder : null; + } + + return ( + + {display.map((key) => ( + {key} + ))} + + ); + }; + + if (items?.length) { + const visibleItems = showUnassigned + ? items + : items.filter((item) => !isUnassigned(getDisplay(item.id))); + + return ( +
+ {label} + {visibleItems.length > 0 && ( +
+ {visibleItems.map((item) => ( + + {item.label} + {renderShortcut(item.id)} + + ))} +
+ )} +
+ ); + } + + return ( + + {label} + {renderShortcut(hotkeyId)} + + ); +} diff --git a/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts b/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts new file mode 100644 index 00000000000..17db513479f --- /dev/null +++ b/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts @@ -0,0 +1,2 @@ +export type { HotkeyTooltipContentItem } from "./HotkeyTooltipContent"; +export { HotkeyTooltipContent } from "./HotkeyTooltipContent"; diff --git a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx index 617f107227b..7804bc17651 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx +++ b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx @@ -38,6 +38,7 @@ import warpIcon from "renderer/assets/app-icons/warp.png"; import webstormIcon from "renderer/assets/app-icons/webstorm.svg"; import xcodeIcon from "renderer/assets/app-icons/xcode.svg"; import { trpc } from "renderer/lib/trpc"; +import { useHotkeyText } from "renderer/stores/hotkeys"; interface AppOption { id: ExternalApp; @@ -110,6 +111,11 @@ export function OpenInButton({ }: OpenInButtonProps) { const [isOpen, setIsOpen] = useState(false); const utils = trpc.useUtils(); + const openInShortcut = useHotkeyText("OPEN_IN_APP"); + const copyPathShortcut = useHotkeyText("COPY_PATH"); + const showOpenInShortcut = showShortcuts && openInShortcut !== "Unassigned"; + const showCopyPathShortcut = + showShortcuts && copyPathShortcut !== "Unassigned"; const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); @@ -159,7 +165,9 @@ export function OpenInButton({ - {`Open in ${currentApp.displayLabel ?? currentApp.label}${showShortcuts ? " (⌘O)" : ""}`} + {`Open in ${currentApp.displayLabel ?? currentApp.label}${ + showOpenInShortcut ? ` (${openInShortcut})` : "" + }`} )} @@ -190,8 +198,10 @@ export function OpenInButton({ /> {app.label} - {showShortcuts && app.id === lastUsedApp && ( - ⌘O + {showOpenInShortcut && app.id === lastUsedApp && ( + + {openInShortcut} + )} ))} @@ -250,8 +260,10 @@ export function OpenInButton({ /> {app.label} - {showShortcuts && app.id === lastUsedApp && ( - ⌘O + {showOpenInShortcut && app.id === lastUsedApp && ( + + {openInShortcut} + )} ))} @@ -266,8 +278,10 @@ export function OpenInButton({ Copy path - {showShortcuts && ( - ⌘⇧C + {showCopyPathShortcut && ( + + {copyPathShortcut} + )} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index e1a59ce987c..edefa71b024 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -1,6 +1,17 @@ +import type { HotkeysState } from "shared/hotkeys"; import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { trpcClient } from "./trpc-client"; +/** + * Flag to skip the next hotkeys persist operation. + * Used when syncing from remote to avoid echo writes. + */ +let skipNextHotkeysPersist = false; + +export function setSkipNextHotkeysPersist(skip: boolean): void { + skipNextHotkeysPersist = skip; +} + /** * Creates a Zustand storage adapter that uses tRPC for persistence. * This ensures all state is persisted through the centralized appState lowdb instance. @@ -60,6 +71,27 @@ export const trpcThemeStorage = createJSONStorage(() => }), ); +/** + * Zustand storage adapter for hotkeys state using tRPC + */ +export const trpcHotkeysStorage = createJSONStorage(() => + createTrpcStorageAdapter({ + get: async () => { + const hotkeysState = await trpcClient.uiState.hotkeys.get.query(); + return { hotkeysState }; + }, + set: (input) => { + // Skip persistence when syncing from remote to avoid echo writes + if (skipNextHotkeysPersist) { + skipNextHotkeysPersist = false; + return Promise.resolve(); + } + const state = input as { hotkeysState: HotkeysState }; + return trpcClient.uiState.hotkeys.set.mutate(state.hotkeysState); + }, + }), +); + /** * Zustand storage adapter for ringtone state using tRPC. * Only the selectedRingtoneId is persisted. diff --git a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx index c49211f1111..1ac9dacb7e4 100644 --- a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx @@ -25,7 +25,7 @@ import { import { LuLifeBuoy } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useOpenSettings, useOpenTasks } from "renderer/stores"; -import { HOTKEYS } from "shared/hotkeys"; +import { useHotkeyDisplay } from "renderer/stores/hotkeys"; export function AvatarDropdown() { const { data: user } = trpc.user.me.useQuery(); @@ -34,6 +34,7 @@ export function AvatarDropdown() { const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); + const hotkeysShortcut = useHotkeyDisplay("SHOW_HOTKEYS"); const signOutMutation = trpc.auth.signOut.useMutation({ onSuccess: () => toast.success("Signed out"), }); @@ -117,7 +118,7 @@ export function AvatarDropdown() { Hotkeys - {HOTKEYS.SHOW_HOTKEYS.display.map((key) => ( + {hotkeysShortcut.map((key) => ( {key} ))} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx index 805cc071ca5..c67578c3a0b 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx @@ -1,9 +1,8 @@ import { Button } from "@superset/ui/button"; -import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { CiSettings } from "react-icons/ci"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useOpenSettings } from "renderer/stores"; -import { HOTKEYS } from "shared/hotkeys"; export function SettingsButton() { const openSettings = useOpenSettings(); @@ -22,14 +21,7 @@ export function SettingsButton() { - - Open settings - - {HOTKEYS.SHOW_HOTKEYS.display.map((key) => ( - {key} - ))} - - + ); diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx index 5cdcff698ea..4917bbdbd76 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx @@ -1,12 +1,33 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Kbd, KbdGroup } from "@superset/ui/kbd"; -import { useState } from "react"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useMemo, useState } from "react"; import { HiMagnifyingGlass } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { + captureHotkeyFromEvent, + getHotkeyConflict, + useHotkeyDisplay, + useHotkeysByCategory, + useHotkeysStore, +} from "renderer/stores/hotkeys"; import { - getHotkeysByCategory, + formatHotkeyText, HOTKEYS, type HotkeyCategory, - type HotkeyWithDisplay, + type HotkeyId, + type HotkeysState, + isOsReservedHotkey, + isTerminalReservedHotkey, } from "shared/hotkeys"; const CATEGORY_ORDER: HotkeyCategory[] = [ @@ -18,82 +39,244 @@ const CATEGORY_ORDER: HotkeyCategory[] = [ ]; function HotkeyRow({ - hotkey, - isEven, + id, + label, + description, + isRecording, + onStartRecording, + onReset, }: { - hotkey: HotkeyWithDisplay; - isEven: boolean; + id: HotkeyId; + label: string; + description?: string; + isRecording: boolean; + onStartRecording: () => void; + onReset: () => void; }) { + const display = useHotkeyDisplay(id); + return ( -
- {hotkey.label} - - {hotkey.display.map((key) => ( - {key} - ))} - +
+
+ {label} + {description && ( + {description} + )} +
+
+ + +
); } -/** - * Consolidate individual workspace jump shortcuts (1-9) into a single entry - */ -function consolidateWorkspaceJumps( - hotkeys: HotkeyWithDisplay[], -): HotkeyWithDisplay[] { - const workspaceJumpPattern = /^Switch to Workspace \d$/; - const hasWorkspaceJumps = hotkeys.some((h) => - workspaceJumpPattern.test(h.label), +export function KeyboardShortcutsSettings() { + const [searchQuery, setSearchQuery] = useState(""); + const [recordingId, setRecordingId] = useState(null); + const [pendingConflict, setPendingConflict] = useState<{ + id: HotkeyId; + keys: string; + conflictId: HotkeyId; + } | null>(null); + const [pendingImport, setPendingImport] = useState<{ + path: string; + state: HotkeysState; + summary: { assigned: number; disabled: number }; + } | null>(null); + + const platform = useHotkeysStore((state) => state.platform); + const setHotkey = useHotkeysStore((state) => state.setHotkey); + const setHotkeysBatch = useHotkeysStore((state) => state.setHotkeysBatch); + const resetHotkey = useHotkeysStore((state) => state.resetHotkey); + const resetAllHotkeys = useHotkeysStore((state) => state.resetAllHotkeys); + const replaceHotkeysState = useHotkeysStore( + (state) => state.replaceHotkeysState, ); + const hotkeysByCategory = useHotkeysByCategory(); - if (!hasWorkspaceJumps) return hotkeys; + const exportMutation = trpc.hotkeys.export.useMutation(); + const importMutation = trpc.hotkeys.import.useMutation(); - const filtered = hotkeys.filter((h) => !workspaceJumpPattern.test(h.label)); - // Reuse the meta key symbol from an existing hotkey's display - const [metaKey] = HOTKEYS.JUMP_TO_WORKSPACE_1.display; - filtered.unshift({ - keys: "meta+1-9", - label: "Switch to Workspace 1-9", - category: "Workspace", - display: [metaKey, "1-9"], - }); + const showHotkeysDisplay = useHotkeyDisplay("SHOW_HOTKEYS"); - return filtered; -} + const allHotkeys = useMemo( + () => + CATEGORY_ORDER.flatMap((category) => hotkeysByCategory[category] ?? []), + [hotkeysByCategory], + ); -export function KeyboardShortcutsSettings() { - const [searchQuery, setSearchQuery] = useState(""); - const hotkeysByCategory = getHotkeysByCategory(); - // Reuse the meta key symbol from SHOW_HOTKEYS display - const [modifierKey] = HOTKEYS.SHOW_HOTKEYS.display; + const filteredHotkeys = useMemo(() => { + if (!searchQuery) return allHotkeys; + const lower = searchQuery.toLowerCase(); + return allHotkeys.filter((hotkey) => + hotkey.label.toLowerCase().includes(lower), + ); + }, [allHotkeys, searchQuery]); - // Flatten and consolidate all hotkeys - const allHotkeys = CATEGORY_ORDER.flatMap((category) => - consolidateWorkspaceJumps(hotkeysByCategory[category]), - ); + useEffect(() => { + if (!recordingId) return; + + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); - // Filter based on search query - const filteredHotkeys = searchQuery - ? allHotkeys.filter((hotkey) => - hotkey.label.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : allHotkeys; + if (event.key === "Escape") { + setRecordingId(null); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + setHotkey(recordingId, null); + setRecordingId(null); + return; + } + + const captured = captureHotkeyFromEvent(event, platform); + if (!captured) return; + + if (isTerminalReservedHotkey(captured)) { + toast.error("That shortcut is reserved by the terminal."); + setRecordingId(null); + return; + } + + const conflictId = getHotkeyConflict(captured, recordingId); + if (conflictId) { + setPendingConflict({ id: recordingId, keys: captured, conflictId }); + setRecordingId(null); + return; + } + + if (isOsReservedHotkey(captured, platform)) { + toast.warning("This shortcut may be reserved by your OS."); + } + + setHotkey(recordingId, captured); + setRecordingId(null); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [recordingId, platform, setHotkey]); + + const handleStartRecording = (id: HotkeyId) => { + setRecordingId((current) => (current === id ? null : id)); + }; + + const handleExport = async () => { + try { + const result = await exportMutation.mutateAsync(); + if ("canceled" in result && result.canceled) return; + if ("error" in result) { + toast.error("Failed to export shortcuts", { + description: result.error, + }); + return; + } + toast.success("Keyboard shortcuts exported", { + description: result.path, + }); + } catch (error) { + toast.error("Failed to export shortcuts", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const handleImport = async () => { + try { + const result = await importMutation.mutateAsync(); + if ("canceled" in result && result.canceled) return; + if ("error" in result) { + toast.error("Failed to import shortcuts", { + description: result.error, + }); + return; + } + setPendingImport({ + path: result.path, + state: result.state, + summary: result.summary, + }); + } catch (error) { + toast.error("Failed to import shortcuts", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const handleConfirmImport = () => { + if (!pendingImport) return; + replaceHotkeysState(pendingImport.state); + toast.success("Keyboard shortcuts imported"); + setPendingImport(null); + }; + + const handleConflictReassign = () => { + if (!pendingConflict) return; + setHotkeysBatch({ + [pendingConflict.conflictId]: null, + [pendingConflict.id]: pendingConflict.keys, + }); + if (isOsReservedHotkey(pendingConflict.keys, platform)) { + toast.warning("This shortcut may be reserved by your OS."); + } + setPendingConflict(null); + }; return ( -
+
{/* Header */} -
-

Keyboard Shortcuts

-

- View all available keyboard shortcuts. Press{" "} - {modifierKey} - ? to open this page anytime. -

+
+
+

Keyboard Shortcuts

+

+ Customize keyboard shortcuts for your workflow. Press{" "} + + {showHotkeysDisplay.map((key) => ( + {key} + ))} + {" "} + to open this page anytime. +

+
+
+ + + +
{/* Search */} @@ -110,7 +293,6 @@ export function KeyboardShortcutsSettings() { {/* Table */}
- {/* Table Header */}
Command @@ -120,14 +302,17 @@ export function KeyboardShortcutsSettings() {
- {/* Table Body */} -
+
{filteredHotkeys.length > 0 ? ( - filteredHotkeys.map((hotkey, index) => ( + filteredHotkeys.map((hotkey) => ( handleStartRecording(hotkey.id)} + onReset={() => resetHotkey(hotkey.id)} /> )) ) : ( @@ -137,6 +322,90 @@ export function KeyboardShortcutsSettings() { )}
+ + {/* Conflict dialog */} + setPendingConflict(null)} + > + + + + Shortcut already in use + + +
+ + {pendingConflict + ? `${formatHotkeyText( + pendingConflict.keys, + platform, + )} is already assigned to “${ + HOTKEYS[pendingConflict.conflictId].label + }”.` + : ""} + + Would you like to reassign it? +
+
+
+ + + + +
+
+ + {/* Import dialog */} + setPendingImport(null)} + > + + + + Import keyboard shortcuts? + + +
+ + This will replace your shortcuts on all platforms. + + {pendingImport && ( + + {pendingImport.summary.assigned} assigned,{" "} + {pendingImport.summary.disabled} disabled on {platform}. + + )} +
+
+
+ + + + +
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx index e729d806578..6fa45f91846 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx @@ -1,9 +1,8 @@ import { Button } from "@superset/ui/button"; -import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniBars3, HiMiniBars3BottomLeft } from "react-icons/hi2"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useSidebarStore } from "renderer/stores"; -import { HOTKEYS } from "shared/hotkeys"; export function SidebarControl() { const { isSidebarOpen, toggleSidebar } = useSidebarStore(); @@ -26,14 +25,10 @@ export function SidebarControl() { - - Toggle sidebar - - {HOTKEYS.TOGGLE_SIDEBAR.display.map((key) => ( - {key} - ))} - - + ); diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx index b654288c8c1..82ee2289908 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx @@ -9,16 +9,16 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useCallback, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { HiFolderOpen, HiMiniPlus, HiOutlineBolt } from "react-icons/hi2"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateBranchWorkspace, useCreateWorkspace, } from "renderer/react-query/workspaces"; +import { useAppHotkey, useHotkeyText } from "renderer/stores/hotkeys"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { HOTKEYS } from "shared/hotkeys"; export interface CreateWorkspaceButtonProps { className?: string; @@ -114,9 +114,22 @@ export function CreateWorkspaceButton({ if (!isLoading) handleOpenNewProject(); }, [isLoading, handleOpenNewProject]); - useHotkeys(HOTKEYS.NEW_WORKSPACE.keys, handleModalCreate); - useHotkeys(HOTKEYS.QUICK_CREATE_WORKSPACE.keys, handleQuickCreateHotkey); - useHotkeys(HOTKEYS.OPEN_PROJECT.keys, handleOpenProjectHotkey); + useAppHotkey("NEW_WORKSPACE", handleModalCreate, undefined, [ + handleModalCreate, + ]); + useAppHotkey("QUICK_CREATE_WORKSPACE", handleQuickCreateHotkey, undefined, [ + handleQuickCreateHotkey, + ]); + useAppHotkey("OPEN_PROJECT", handleOpenProjectHotkey, undefined, [ + handleOpenProjectHotkey, + ]); + + const newWorkspaceShortcut = useHotkeyText("NEW_WORKSPACE"); + const quickCreateShortcut = useHotkeyText("QUICK_CREATE_WORKSPACE"); + const openProjectShortcut = useHotkeyText("OPEN_PROJECT"); + const showNewWorkspaceShortcut = newWorkspaceShortcut !== "Unassigned"; + const showQuickCreateShortcut = quickCreateShortcut !== "Unassigned"; + const showOpenProjectShortcut = openProjectShortcut !== "Unassigned"; return ( @@ -134,7 +147,17 @@ export function CreateWorkspaceButton({ - Create workspace or project + New Workspace - ⌘N + {showNewWorkspaceShortcut && ( + + {newWorkspaceShortcut} + + )} Quick Create - - ⌘⇧N - + {showQuickCreateShortcut && ( + + {quickCreateShortcut} + + )} Open Project - - ⌘⇧O - + {showOpenProjectShortcut && ( + + {openProjectShortcut} + + )} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index fedaaba8267..27c58551259 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -1,5 +1,4 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useCreateBranchWorkspace, @@ -9,7 +8,7 @@ import { useCurrentView, useIsSettingsTabOpen, } from "renderer/stores/app-state"; -import { HOTKEYS } from "shared/hotkeys"; +import { useAppHotkey } from "renderer/stores/hotkeys"; import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; import { SettingsTab } from "./SettingsTab"; import { WorkspaceGroup } from "./WorkspaceGroup"; @@ -79,20 +78,11 @@ export function WorkspacesTabs() { // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); - // Workspace switching shortcuts (⌘+1-9) - combined into single hook call - const workspaceKeys = Array.from( - { length: 9 }, - (_, i) => `meta+${i + 1}`, - ).join(", "); - const handleWorkspaceSwitch = useCallback( - (event: KeyboardEvent) => { - const num = Number(event.key); - if (num >= 1 && num <= 9) { - const workspace = allWorkspaces[num - 1]; - if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); - } + (index: number) => { + const workspace = allWorkspaces[index]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); } }, [allWorkspaces, setActiveWorkspace], @@ -118,9 +108,66 @@ export function WorkspacesTabs() { } }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - useHotkeys(workspaceKeys, handleWorkspaceSwitch); - useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); - useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + useAppHotkey( + "JUMP_TO_WORKSPACE_1", + () => handleWorkspaceSwitch(0), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_2", + () => handleWorkspaceSwitch(1), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_3", + () => handleWorkspaceSwitch(2), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_4", + () => handleWorkspaceSwitch(3), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_5", + () => handleWorkspaceSwitch(4), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_6", + () => handleWorkspaceSwitch(5), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_7", + () => handleWorkspaceSwitch(6), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_8", + () => handleWorkspaceSwitch(7), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey( + "JUMP_TO_WORKSPACE_9", + () => handleWorkspaceSwitch(8), + undefined, + [handleWorkspaceSwitch], + ); + useAppHotkey("PREV_WORKSPACE", handlePrevWorkspace, undefined, [ + handlePrevWorkspace, + ]); + useAppHotkey("NEXT_WORKSPACE", handleNextWorkspace, undefined, [ + handleNextWorkspace, + ]); useEffect(() => { const checkScroll = () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index d59d010c0d5..0f9a435a873 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -1,10 +1,16 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { HiMiniCommandLine } from "react-icons/hi2"; -import { HOTKEYS } from "shared/hotkeys"; - -const shortcuts = [HOTKEYS.NEW_TERMINAL, HOTKEYS.OPEN_IN_APP]; +import { useHotkeyDisplay } from "renderer/stores/hotkeys"; export function EmptyTabView() { + const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); + const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); + + const shortcuts = [ + { label: "New Terminal", display: newTerminalDisplay }, + { label: "Open in App", display: openInAppDisplay }, + ]; + return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx index 72af551ee4f..25868490187 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx @@ -18,6 +18,7 @@ import { LuRows2, LuX, } from "react-icons/lu"; +import { useHotkeyText } from "renderer/stores/hotkeys"; import type { Tab } from "renderer/stores/tabs/types"; interface TabContentContextMenuProps { @@ -45,6 +46,8 @@ export function TabContentContextMenu({ }: TabContentContextMenuProps) { // Filter out current tab from available targets const targetTabs = availableTabs.filter((t) => t.id !== currentTabId); + const clearShortcut = useHotkeyText("CLEAR_TERMINAL"); + const showClearShortcut = clearShortcut !== "Unassigned"; return ( @@ -61,7 +64,9 @@ export function TabContentContextMenu({ Clear Terminal - ⌘K + {showClearShortcut && ( + {clearShortcut} + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index fe5d32dda1a..6e09a646d23 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -4,6 +4,7 @@ import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { registerPaneRef, unregisterPaneRef, @@ -150,7 +151,10 @@ export function TabPane({ - Split pane + @@ -164,7 +168,10 @@ export function TabPane({ - Close pane +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 82589e84467..20a261a4d85 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -4,12 +4,11 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; +import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; -import { HOTKEYS } from "shared/hotkeys"; import { sanitizeForTitle } from "./commandBuffer"; import { createTerminalInstance, @@ -179,8 +178,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }, [isFocused]); - useHotkeys( - HOTKEYS.FIND_IN_TERMINAL.keys, + useAppHotkey( + "FIND_IN_TERMINAL", () => { setIsSearchOpen((prev) => !prev); }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts index cfc93f8bd23..fe8d2e8e90e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts @@ -19,6 +19,12 @@ mock.module("renderer/lib/trpc-client", () => ({ openUrl: { mutate: mock(() => Promise.resolve()) }, openFileInEditor: { mutate: mock(() => Promise.resolve()) }, }, + uiState: { + hotkeys: { + get: { query: mock(() => Promise.resolve(null)) }, + set: { mutate: mock(() => Promise.resolve()) }, + }, + }, }, })); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 97f29bde11c..2714da69c8f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -8,8 +8,9 @@ import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; import { debounce } from "lodash"; import { trpcClient } from "renderer/lib/trpc-client"; +import { getHotkeyKeys, isAppHotkeyEvent } from "renderer/stores/hotkeys"; import { toXtermTheme } from "renderer/stores/theme/utils"; -import { isAppHotkey } from "shared/hotkeys"; +import { isTerminalReservedEvent, matchesHotkeyEvent } from "shared/hotkeys"; import { builtInThemes, DEFAULT_THEME_ID, @@ -175,7 +176,7 @@ export function createTerminalInstance( export interface KeyboardHandlerOptions { /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ onShiftEnter?: () => void; - /** Callback for Cmd+K to clear the terminal */ + /** Callback for the configured clear terminal shortcut */ onClear?: () => void; } @@ -226,8 +227,8 @@ export function setupPasteHandler( /** * Setup keyboard handling for xterm including: * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook - * - Shift+Enter: Sends ESC+CR sequence (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) - * - Cmd+K: Clears the terminal + * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) + * - Clear terminal: Uses the configured clear shortcut * * Returns a cleanup function to remove the handler. */ @@ -250,12 +251,11 @@ export function setupKeyboardHandler( return false; } + if (isTerminalReservedEvent(event)) return true; + + const clearKeys = getHotkeyKeys("CLEAR_TERMINAL"); const isClearShortcut = - event.key.toLowerCase() === "k" && - event.metaKey && - !event.shiftKey && - !event.ctrlKey && - !event.altKey; + clearKeys !== null && matchesHotkeyEvent(event, clearKeys); if (isClearShortcut) { if (event.type === "keydown" && options.onClear) { @@ -267,7 +267,7 @@ export function setupKeyboardHandler( if (event.type !== "keydown") return true; if (!event.metaKey && !event.ctrlKey) return true; - if (isAppHotkey(event)) { + if (isAppHotkeyEvent(event)) { document.dispatchEvent( new KeyboardEvent(event.type, { key: event.key, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx index 5a70e22eb1a..222cade48cf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx @@ -23,6 +23,7 @@ import { } from "renderer/components/OpenInButton"; import { shortenHomePath } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; +import { useHotkeyText } from "renderer/stores/hotkeys"; interface FormattedPath { prefix: string; @@ -65,6 +66,10 @@ export function WorkspaceActionBarRight({ const formattedPath = formatWorktreePath(worktreePath, homeDir); const currentApp = getAppOption(lastUsedApp); + const openInShortcut = useHotkeyText("OPEN_IN_APP"); + const copyPathShortcut = useHotkeyText("COPY_PATH"); + const showOpenInShortcut = openInShortcut !== "Unassigned"; + const showCopyPathShortcut = copyPathShortcut !== "Unassigned"; const handleOpenInEditor = () => { openInApp.mutate({ path: worktreePath, app: lastUsedApp }); @@ -109,7 +114,7 @@ export function WorkspaceActionBarRight({ Open in {currentApp.displayLabel ?? currentApp.label} - ⌘O + {showOpenInShortcut ? openInShortcut : "—"} @@ -142,8 +147,8 @@ export function WorkspaceActionBarRight({ className="size-4 object-contain mr-2" /> {app.label} - {app.id === lastUsedApp && ( - ⌘O + {app.id === lastUsedApp && showOpenInShortcut && ( + {openInShortcut} )} ))} @@ -204,7 +209,9 @@ export function WorkspaceActionBarRight({ Copy path - ⌘⇧C + {showCopyPathShortcut && ( + {copyPathShortcut} + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 4fb220756e2..661543c8580 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,9 +1,8 @@ import { useMemo } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; +import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; -import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; import { WorkspaceActionBar } from "./WorkspaceActionBar"; @@ -41,73 +40,113 @@ export function WorkspaceView() { const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; // Tab management shortcuts - useHotkeys(HOTKEYS.NEW_TERMINAL.keys, () => { - if (activeWorkspaceId) { - addTab(activeWorkspaceId); - } - }, [activeWorkspaceId, addTab]); + useAppHotkey( + "NEW_TERMINAL", + () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }, + undefined, + [activeWorkspaceId, addTab], + ); - useHotkeys(HOTKEYS.CLOSE_TERMINAL.keys, () => { - // Close focused pane (which may close the tab if it's the last pane) - if (focusedPaneId) { - removePane(focusedPaneId); - } - }, [focusedPaneId, removePane]); + useAppHotkey( + "CLOSE_TERMINAL", + () => { + // Close focused pane (which may close the tab if it's the last pane) + if (focusedPaneId) { + removePane(focusedPaneId); + } + }, + undefined, + [focusedPaneId, removePane], + ); - // Switch between tabs (⌘+Up/Down) - useHotkeys(HOTKEYS.PREV_TERMINAL.keys, () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index > 0) { - setActiveTab(activeWorkspaceId, tabs[index - 1].id); - } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + // Switch between tabs (configurable shortcut) + useAppHotkey( + "PREV_TERMINAL", + () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index > 0) { + setActiveTab(activeWorkspaceId, tabs[index - 1].id); + } + }, + undefined, + [activeWorkspaceId, activeTabId, tabs, setActiveTab], + ); - useHotkeys(HOTKEYS.NEXT_TERMINAL.keys, () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index < tabs.length - 1) { - setActiveTab(activeWorkspaceId, tabs[index + 1].id); - } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + useAppHotkey( + "NEXT_TERMINAL", + () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index < tabs.length - 1) { + setActiveTab(activeWorkspaceId, tabs[index + 1].id); + } + }, + undefined, + [activeWorkspaceId, activeTabId, tabs, setActiveTab], + ); - // Switch between panes within a tab (⌘+⌥+Left/Right) - useHotkeys(HOTKEYS.PREV_PANE.keys, () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); - if (prevPaneId) { - setFocusedPane(activeTabId, prevPaneId); - } - }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + // Switch between panes within a tab (configurable shortcut) + useAppHotkey( + "PREV_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); - useHotkeys(HOTKEYS.NEXT_PANE.keys, () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); - if (nextPaneId) { - setFocusedPane(activeTabId, nextPaneId); - } - }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + useAppHotkey( + "NEXT_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); // Open in last used app shortcut const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); const openInApp = trpc.external.openInApp.useMutation(); - useHotkeys("meta+o", () => { - if (activeWorkspace?.worktreePath) { - openInApp.mutate({ - path: activeWorkspace.worktreePath, - app: lastUsedApp, - }); - } - }, [activeWorkspace?.worktreePath, lastUsedApp]); + useAppHotkey( + "OPEN_IN_APP", + () => { + if (activeWorkspace?.worktreePath) { + openInApp.mutate({ + path: activeWorkspace.worktreePath, + app: lastUsedApp, + }); + } + }, + undefined, + [activeWorkspace?.worktreePath, lastUsedApp], + ); // Copy path shortcut const copyPath = trpc.external.copyPath.useMutation(); - useHotkeys("meta+shift+c", () => { - if (activeWorkspace?.worktreePath) { - copyPath.mutate(activeWorkspace.worktreePath); - } - }, [activeWorkspace?.worktreePath]); + useAppHotkey( + "COPY_PATH", + () => { + if (activeWorkspace?.worktreePath) { + copyPath.mutate(activeWorkspace.worktreePath); + } + }, + undefined, + [activeWorkspace?.worktreePath], + ); return (
diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 3e4c5fcbd1c..98f2cc947a2 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -3,7 +3,6 @@ import { Button } from "@superset/ui/button"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useState } from "react"; import { DndProvider } from "react-dnd"; -import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; @@ -13,13 +12,13 @@ import { useVersionCheck } from "renderer/hooks/useVersionCheck"; import { trpc } from "renderer/lib/trpc"; import { SignInScreen } from "renderer/screens/sign-in"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; +import { useAppHotkey, useHotkeysSync } from "renderer/stores/hotkeys"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; -import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -81,6 +80,7 @@ export function MainScreen() { useAgentHookListener(); useUpdateListener(); + useHotkeysSync(); trpc.menu.subscribe.useSubscription(undefined, { onData: (event) => { @@ -98,13 +98,18 @@ export function MainScreen() { const activeTab = tabs.find((t) => t.id === activeTabId); const isWorkspaceView = currentView === "workspace"; - useHotkeys(HOTKEYS.SHOW_HOTKEYS.keys, () => openSettings("keyboard"), [ + useAppHotkey("SHOW_HOTKEYS", () => openSettings("keyboard"), undefined, [ openSettings, ]); - useHotkeys(HOTKEYS.TOGGLE_SIDEBAR.keys, () => { - if (isWorkspaceView) toggleSidebar(); - }, [toggleSidebar, isWorkspaceView]); + useAppHotkey( + "TOGGLE_SIDEBAR", + () => { + if (isWorkspaceView) toggleSidebar(); + }, + undefined, + [toggleSidebar, isWorkspaceView], + ); /** * Resolves the target pane for split operations. @@ -125,53 +130,80 @@ export function MainScreen() { [setFocusedPane], ); - useHotkeys(HOTKEYS.SPLIT_AUTO.keys, () => { - if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); - if (!target) return; - const dimensions = getPaneDimensions(target.paneId); - if (dimensions) { - splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); + useAppHotkey( + "SPLIT_AUTO", + () => { + if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + const dimensions = getPaneDimensions(target.paneId); + if (dimensions) { + splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); + } } - } - }, [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneAuto, - resolveSplitTarget, - isWorkspaceView, - ]); + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneAuto, + resolveSplitTarget, + isWorkspaceView, + ], + ); - useHotkeys(HOTKEYS.SPLIT_RIGHT.keys, () => { - if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); - if (!target) return; - splitPaneVertical(activeTabId, target.paneId, target.path); - } - }, [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneVertical, - resolveSplitTarget, - isWorkspaceView, - ]); + useAppHotkey( + "SPLIT_RIGHT", + () => { + if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneVertical, + resolveSplitTarget, + isWorkspaceView, + ], + ); - useHotkeys(HOTKEYS.SPLIT_DOWN.keys, () => { - if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); - if (!target) return; - splitPaneHorizontal(activeTabId, target.paneId, target.path); - } - }, [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneHorizontal, - resolveSplitTarget, - isWorkspaceView, - ]); + useAppHotkey( + "SPLIT_DOWN", + () => { + if (isWorkspaceView && activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneHorizontal(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneHorizontal, + resolveSplitTarget, + isWorkspaceView, + ], + ); const isLoading = isWorkspaceLoading; const showStartView = diff --git a/apps/desktop/src/renderer/stores/hotkeys/index.ts b/apps/desktop/src/renderer/stores/hotkeys/index.ts new file mode 100644 index 00000000000..f5990c259bc --- /dev/null +++ b/apps/desktop/src/renderer/stores/hotkeys/index.ts @@ -0,0 +1 @@ +export * from "./store"; diff --git a/apps/desktop/src/renderer/stores/hotkeys/store.ts b/apps/desktop/src/renderer/stores/hotkeys/store.ts new file mode 100644 index 00000000000..39b8e853dd4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/hotkeys/store.ts @@ -0,0 +1,363 @@ +import { useEffect, useMemo, useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { + setSkipNextHotkeysPersist, + trpcHotkeysStorage, +} from "renderer/lib/trpc-storage"; +import { + canonicalizeHotkeyForPlatform, + formatHotkeyDisplay, + formatHotkeyText, + getCurrentPlatform, + getDefaultHotkey, + getEffectiveHotkey, + getEffectiveHotkeysMap, + HOTKEYS, + HOTKEYS_STATE_VERSION, + type HotkeyCategory, + type HotkeyDefinition, + type HotkeyId, + type HotkeyPlatform, + type HotkeysState, + hasPrimaryModifier, + hotkeyFromKeyboardEvent, + isOsReservedHotkey, + isTerminalReservedHotkey, + matchesHotkeyEvent, +} from "shared/hotkeys"; +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +interface HotkeysStoreState { + hotkeysState: HotkeysState; + platform: HotkeyPlatform; + setHotkey: (id: HotkeyId, keys: string | null) => void; + setHotkeysBatch: (updates: Partial>) => void; + resetHotkey: (id: HotkeyId) => void; + resetAllHotkeys: () => void; + replaceHotkeysState: (state: HotkeysState) => void; +} + +const DEFAULT_STATE: HotkeysState = { + version: HOTKEYS_STATE_VERSION, + byPlatform: { darwin: {}, win32: {}, linux: {} }, +}; + +function getOverridesForPlatform( + state: HotkeysState, + platform: HotkeyPlatform, +): Record { + return (state.byPlatform[platform] ?? {}) as Record; +} + +function updateOverrides( + state: HotkeysState, + platform: HotkeyPlatform, + next: Partial>, +): HotkeysState { + return { + ...state, + byPlatform: { + ...state.byPlatform, + [platform]: next, + }, + }; +} + +export const useHotkeysStore = create()( + devtools( + persist( + (set, get) => ({ + hotkeysState: DEFAULT_STATE, + platform: getCurrentPlatform(), + + setHotkey: (id, keys) => { + const platform = get().platform; + const canonical = + keys === null + ? null + : canonicalizeHotkeyForPlatform(keys, platform); + if (keys !== null && !canonical) return; + // App hotkeys must include ctrl or meta to work in terminal + if (canonical !== null && !hasPrimaryModifier(canonical)) return; + + const defaultValue = getDefaultHotkey(id, platform); + const overrides = getOverridesForPlatform( + get().hotkeysState, + platform, + ); + const nextOverrides = { ...overrides }; + + if (canonical === defaultValue) { + delete nextOverrides[id]; + } else { + nextOverrides[id] = canonical; + } + + set((state) => ({ + hotkeysState: updateOverrides( + state.hotkeysState, + platform, + nextOverrides, + ), + })); + }, + + setHotkeysBatch: (updates) => { + const platform = get().platform; + const overrides = getOverridesForPlatform( + get().hotkeysState, + platform, + ); + const nextOverrides = { ...overrides }; + + for (const [id, keys] of Object.entries(updates)) { + const hotkeyId = id as HotkeyId; + const canonical = + keys === null + ? null + : canonicalizeHotkeyForPlatform(keys, platform); + if (keys !== null && !canonical) continue; + // App hotkeys must include ctrl or meta to work in terminal + if (canonical !== null && !hasPrimaryModifier(canonical)) continue; + const defaultValue = getDefaultHotkey(hotkeyId, platform); + if (canonical === defaultValue) { + delete nextOverrides[hotkeyId]; + } else { + nextOverrides[hotkeyId] = canonical; + } + } + + set((state) => ({ + hotkeysState: updateOverrides( + state.hotkeysState, + platform, + nextOverrides, + ), + })); + }, + + resetHotkey: (id) => { + const platform = get().platform; + const overrides = getOverridesForPlatform( + get().hotkeysState, + platform, + ); + if (!(id in overrides)) return; + const nextOverrides = { ...overrides }; + delete nextOverrides[id]; + set((state) => ({ + hotkeysState: updateOverrides( + state.hotkeysState, + platform, + nextOverrides, + ), + })); + }, + + resetAllHotkeys: () => { + const platform = get().platform; + set((state) => ({ + hotkeysState: { + ...state.hotkeysState, + byPlatform: { + ...state.hotkeysState.byPlatform, + [platform]: {}, + }, + }, + })); + }, + + replaceHotkeysState: (state) => { + set({ hotkeysState: state }); + }, + }), + { + name: "hotkeys-storage", + storage: trpcHotkeysStorage, + partialize: (state) => ({ hotkeysState: state.hotkeysState }), + }, + ), + { name: "HotkeysStore" }, + ), +); + +export function useHotkeyKeys(id: HotkeyId): string | null { + return useHotkeysStore((state) => { + const overrides = getOverridesForPlatform( + state.hotkeysState, + state.platform, + ); + return getEffectiveHotkey(id, overrides, state.platform); + }); +} + +export function getHotkeyKeys(id: HotkeyId): string | null { + const state = useHotkeysStore.getState(); + const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); + return getEffectiveHotkey(id, overrides, state.platform); +} + +export function useHotkeyDisplay(id: HotkeyId): string[] { + const platform = useHotkeysStore((state) => state.platform); + const keys = useHotkeyKeys(id); + return useMemo(() => formatHotkeyDisplay(keys, platform), [keys, platform]); +} + +export function useHotkeyText(id: HotkeyId): string { + return useHotkeysStore((state) => { + const overrides = getOverridesForPlatform( + state.hotkeysState, + state.platform, + ); + const keys = getEffectiveHotkey(id, overrides, state.platform); + return formatHotkeyText(keys, state.platform); + }); +} + +export function useEffectiveHotkeysMap(): Record { + const platform = useHotkeysStore((state) => state.platform); + const hotkeysState = useHotkeysStore((state) => state.hotkeysState); + return useMemo(() => { + const overrides = getOverridesForPlatform(hotkeysState, platform); + return getEffectiveHotkeysMap(overrides, platform); + }, [hotkeysState, platform]); +} + +export function useHotkeysByCategory(options?: { + includeHidden?: boolean; +}): Record> { + return useMemo(() => { + const grouped: Record< + HotkeyCategory, + Array + > = { + Workspace: [], + Layout: [], + Terminal: [], + Window: [], + Help: [], + }; + + for (const [id, hotkey] of Object.entries(HOTKEYS)) { + if (!options?.includeHidden && hotkey.isHidden) continue; + grouped[hotkey.category].push({ id: id as HotkeyId, ...hotkey }); + } + return grouped; + }, [options?.includeHidden]); +} + +export function isAppHotkeyEvent(event: KeyboardEvent): boolean { + const state = useHotkeysStore.getState(); + const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); + const effective = getEffectiveHotkeysMap(overrides, state.platform); + return (Object.keys(effective) as HotkeyId[]).some((id) => { + const keys = effective[id]; + if (!keys) return false; + return matchesHotkeyEvent(event, keys); + }); +} + +export function isReservedHotkey( + keys: string, + platform: HotkeyPlatform, +): { + isTerminalReserved: boolean; + isOsReserved: boolean; +} { + return { + isTerminalReserved: isTerminalReservedHotkey(keys), + isOsReserved: isOsReservedHotkey(keys, platform), + }; +} + +export function getHotkeyConflict( + keys: string, + excludeId?: HotkeyId, +): HotkeyId | null { + const state = useHotkeysStore.getState(); + const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); + const effective = getEffectiveHotkeysMap(overrides, state.platform); + const canonical = canonicalizeHotkeyForPlatform(keys, state.platform); + if (!canonical) return null; + + for (const [id, value] of Object.entries(effective)) { + if (id === excludeId) continue; + if (value === canonical) return id as HotkeyId; + } + return null; +} + +export function useHotkeysSync() { + const platform = useHotkeysStore((state) => state.platform); + const replace = useHotkeysStore((state) => state.replaceHotkeysState); + + trpc.uiState.hotkeys.subscribe.useSubscription(undefined, { + onData: () => { + trpcClient.uiState.hotkeys.get + .query() + .then((state: HotkeysState) => { + const current = useHotkeysStore.getState().hotkeysState; + // Use structural comparison that's order-independent + const currentStr = JSON.stringify( + current, + Object.keys(current).sort(), + ); + const newStr = JSON.stringify(state, Object.keys(state).sort()); + if (currentStr === newStr) { + return; + } + // Skip persistence to avoid echo writes back to storage + setSkipNextHotkeysPersist(true); + replace(state); + }) + .catch((error: unknown) => { + console.error("[hotkeys] Failed to sync hotkeys:", error); + }); + }, + }); + + return platform; +} + +export function captureHotkeyFromEvent( + event: KeyboardEvent, + platform: HotkeyPlatform, +): string | null { + return hotkeyFromKeyboardEvent(event, platform); +} + +export function useAppHotkey( + id: HotkeyId, + callback: (event: KeyboardEvent, handler: unknown) => void, + options?: { enabled?: boolean; preventDefault?: boolean }, + deps: unknown[] = [], +) { + const keys = useHotkeyKeys(id); + const enabled = Boolean(keys) && (options?.enabled ?? true); + const preventDefault = options?.preventDefault ?? false; + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!enabled || !keys) return; + if ( + typeof document === "undefined" || + typeof document.addEventListener !== "function" + ) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (!matchesHotkeyEvent(event, keys)) return; + if (preventDefault) event.preventDefault(); + callbackRef.current(event, undefined); + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [enabled, keys, preventDefault, ...deps]); +} diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 5f03605755e..b289f0bfb38 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -1,4 +1,5 @@ export * from "./app-state"; +export * from "./hotkeys"; export * from "./markdown-preferences"; export * from "./ports"; export * from "./ringtone"; diff --git a/apps/desktop/src/shared/hotkeys.test.ts b/apps/desktop/src/shared/hotkeys.test.ts new file mode 100644 index 00000000000..0a2f1ab405b --- /dev/null +++ b/apps/desktop/src/shared/hotkeys.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { + canonicalizeHotkey, + canonicalizeHotkeyForPlatform, + deriveNonMacDefault, + hotkeyFromKeyboardEvent, + isTerminalReservedEvent, + toElectronAccelerator, +} from "./hotkeys"; + +describe("canonicalizeHotkey", () => { + it("normalizes modifier order", () => { + expect(canonicalizeHotkey("shift+meta+k")).toBe("meta+shift+k"); + }); + + it("rejects invalid hotkeys", () => { + expect(canonicalizeHotkey("shift+meta+k+x")).toBeNull(); + }); +}); + +describe("canonicalizeHotkeyForPlatform", () => { + it("rejects meta on non-mac platforms", () => { + expect(canonicalizeHotkeyForPlatform("meta+k", "win32")).toBeNull(); + }); +}); + +describe("deriveNonMacDefault", () => { + it("returns null for null input", () => { + expect(deriveNonMacDefault(null)).toBeNull(); + }); + + it("returns null for invalid hotkey", () => { + expect(deriveNonMacDefault("invalid+key+combo+extra")).toBeNull(); + }); + + it("returns unchanged hotkey when no meta modifier present", () => { + expect(deriveNonMacDefault("ctrl+k")).toBe("ctrl+k"); + }); + + it("maps meta+key to ctrl+shift+key (simple meta case)", () => { + expect(deriveNonMacDefault("meta+k")).toBe("ctrl+shift+k"); + }); + + it("maps meta+shift to ctrl+alt+shift (adds alt for shifted defaults)", () => { + expect(deriveNonMacDefault("meta+shift+w")).toBe("ctrl+alt+shift+w"); + }); + + it("maps meta+alt to ctrl+alt+shift", () => { + expect(deriveNonMacDefault("meta+alt+k")).toBe("ctrl+alt+shift+k"); + }); +}); + +describe("hotkeyFromKeyboardEvent", () => { + it("captures a simple meta hotkey on mac", () => { + const keys = hotkeyFromKeyboardEvent( + { + key: "k", + code: "KeyK", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "darwin", + ); + expect(keys).toBe("meta+k"); + }); +}); + +describe("toElectronAccelerator", () => { + it("converts to electron accelerator for mac", () => { + expect(toElectronAccelerator("meta+shift+w", "darwin")).toBe( + "Command+Shift+W", + ); + }); + + it("returns null for meta on non-mac", () => { + expect(toElectronAccelerator("meta+w", "win32")).toBeNull(); + }); +}); + +describe("isTerminalReservedEvent", () => { + it("detects ctrl+c", () => { + expect( + isTerminalReservedEvent({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index f9d07eee468..c4e38007bbe 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -5,15 +5,80 @@ import { PLATFORM } from "./constants"; -// Platform-specific modifier key symbols -const MODIFIER_MAP = PLATFORM.IS_MAC - ? { meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" } - : PLATFORM.IS_WINDOWS - ? { meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" } - : { meta: "Super", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }; - -const KEY_MAP: Record = { - ...MODIFIER_MAP, +export type HotkeyPlatform = "darwin" | "win32" | "linux"; + +export type HotkeyCategory = + | "Workspace" + | "Layout" + | "Terminal" + | "Window" + | "Help"; + +export interface HotkeyDefinition { + /** Human-readable label for display */ + label: string; + /** Category for grouping in the modal */ + category: HotkeyCategory; + /** Optional description for more detail */ + description?: string; + /** Per-platform defaults */ + defaults: Record; + /** Hide from settings list (reserved for future use) */ + isHidden?: boolean; +} + +export type HotkeyId = keyof typeof HOTKEYS; + +export type HotkeyWithId = HotkeyDefinition & { id: HotkeyId }; + +export interface HotkeysState { + version: number; + byPlatform: Record>>; +} + +export interface HotkeysExportFile { + schemaVersion: number; + exportedAt: string; + app: string; + hotkeys: Record>>; +} + +export const HOTKEYS_STATE_VERSION = 1; + +const MODIFIER_ORDER: Array<"meta" | "ctrl" | "alt" | "shift"> = [ + "meta", + "ctrl", + "alt", + "shift", +]; + +const KEY_ALIAS_MAP: Record = { + cmd: "meta", + command: "meta", + opt: "alt", + option: "alt", + control: "ctrl", + ctl: "ctrl", + esc: "escape", + return: "enter", + arrowleft: "left", + arrowright: "right", + arrowup: "up", + arrowdown: "down", + " ": "space", + spacebar: "space", + slash: "slash", + "/": "slash", + "?": "slash", +}; + +const MODIFIER_DISPLAY_MAP: Record> = { + darwin: { meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" }, + win32: { meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, + linux: { meta: "Super", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, +}; + +const KEY_DISPLAY_MAP: Record = { enter: "↵", backspace: "⌫", delete: "⌦", @@ -27,118 +92,353 @@ const KEY_MAP: Record = { slash: "/", }; -/** Format a key string for display (e.g., "meta+shift+d" -> ["⌘", "⇧", "D"]) */ -function formatKeys(keys: string): string[] { - return keys.split("+").map((key) => { - const lower = key.toLowerCase(); - return KEY_MAP[lower] || key.toUpperCase(); - }); +const ELECTRON_KEY_MAP: Record = { + enter: "Enter", + backspace: "Backspace", + delete: "Delete", + escape: "Escape", + tab: "Tab", + up: "Up", + down: "Down", + left: "Left", + right: "Right", + space: "Space", + slash: "/", +}; + +const TERMINAL_RESERVED_CHORDS = new Set([ + "ctrl+c", + "ctrl+d", + "ctrl+z", + "ctrl+s", + "ctrl+q", + "ctrl+\\", +]); + +const OS_RESERVED_CHORDS: Record = { + darwin: ["meta+q", "meta+space", "meta+tab"], + win32: ["alt+f4", "alt+tab", "ctrl+alt+delete"], + linux: ["alt+f4", "alt+tab"], +}; + +export interface KeyboardEventLike { + key: string; + code?: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; } -/** Helper to define a hotkey with pre-computed display */ -function hotkey(def: T): T & { display: string[] } { - return { ...def, display: formatKeys(def.keys) }; +function normalizeKey(raw: string): string { + const trimmed = raw.trim(); + const lower = trimmed === "" && raw !== "" ? raw : trimmed.toLowerCase(); + return KEY_ALIAS_MAP[lower] ?? lower; } -export type HotkeyCategory = - | "Workspace" - | "Layout" - | "Terminal" - | "Window" - | "Help"; +function parseHotkeyString(keys: string): { + modifiers: Set; + key: string | null; +} { + const parts = keys + .split("+") + .map((part) => normalizeKey(part)) + .filter(Boolean); + const modifiers = new Set(); + let primary: string | null = null; -interface HotkeyDefinition { - /** Key combination for react-hotkeys-hook (e.g., "meta+s") */ - keys: string; - /** Human-readable label for display */ + for (const part of parts) { + if (MODIFIER_ORDER.includes(part as (typeof MODIFIER_ORDER)[number])) { + modifiers.add(part); + continue; + } + if (primary) { + return { modifiers, key: null }; + } + primary = part; + } + + return { modifiers, key: primary }; +} + +function formatHotkeyString(modifiers: Set, key: string): string { + const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)); + return [...ordered, key].join("+"); +} + +export function getCurrentPlatform(): HotkeyPlatform { + if (PLATFORM.IS_MAC) return "darwin"; + if (PLATFORM.IS_WINDOWS) return "win32"; + return "linux"; +} + +export function canonicalizeHotkey(keys: string): string | null { + const parsed = parseHotkeyString(keys); + if (!parsed.key) return null; + return formatHotkeyString(parsed.modifiers, parsed.key); +} + +export function canonicalizeHotkeyForPlatform( + keys: string, + platform: HotkeyPlatform, +): string | null { + const canonical = canonicalizeHotkey(keys); + if (!canonical) return null; + if (platform !== "darwin" && canonical.includes("meta+")) return null; + return canonical; +} + +export function formatHotkeyDisplay( + keys: string | null, + platform: HotkeyPlatform, +): string[] { + if (!keys) return ["Unassigned"]; + const canonical = canonicalizeHotkey(keys); + if (!canonical) return ["Unassigned"]; + + const { modifiers, key } = parseHotkeyString(canonical); + if (!key) return ["Unassigned"]; + + const modifierSymbols = MODIFIER_ORDER.filter((modifier) => + modifiers.has(modifier), + ).map((modifier) => MODIFIER_DISPLAY_MAP[platform][modifier]); + + const keyDisplay = KEY_DISPLAY_MAP[key] ?? key.toUpperCase(); + return [...modifierSymbols, keyDisplay]; +} + +export function formatHotkeyText( + keys: string | null, + platform: HotkeyPlatform, +): string { + const display = formatHotkeyDisplay(keys, platform); + if (display.length === 1 && display[0] === "Unassigned") { + return "Unassigned"; + } + return platform === "darwin" ? display.join("") : display.join("+"); +} + +export function matchesHotkeyEvent( + event: KeyboardEventLike, + keys: string, +): boolean { + const canonical = canonicalizeHotkey(keys); + if (!canonical) return false; + + const { modifiers, key } = parseHotkeyString(canonical); + if (!key) return false; + + const requiresMeta = modifiers.has("meta"); + const requiresCtrl = modifiers.has("ctrl"); + const requiresAlt = modifiers.has("alt"); + const requiresShift = modifiers.has("shift"); + + if (requiresMeta !== event.metaKey) return false; + if (requiresCtrl !== event.ctrlKey) return false; + if (requiresAlt !== event.altKey) return false; + if (requiresShift !== event.shiftKey) return false; + + const eventKey = normalizeKey(event.key); + const eventCode = event.code ? normalizeKey(event.code) : ""; + + if (key === "slash" && (eventKey === "slash" || eventCode === "slash")) { + return true; + } + + if (key === "left" && eventKey === "arrowleft") return true; + if (key === "right" && eventKey === "arrowright") return true; + if (key === "up" && eventKey === "arrowup") return true; + if (key === "down" && eventKey === "arrowdown") return true; + + return eventKey === key; +} + +export function hotkeyFromKeyboardEvent( + event: KeyboardEventLike, + platform: HotkeyPlatform, +): string | null { + const normalizedKey = normalizeKey(event.key); + if ( + normalizedKey === "shift" || + normalizedKey === "ctrl" || + normalizedKey === "alt" || + normalizedKey === "meta" + ) { + return null; + } + if (normalizedKey === "dead" || normalizedKey === "unidentified") { + return null; + } + + // App hotkeys must include ctrl or meta to avoid conflicts with terminal input + if (!event.ctrlKey && !event.metaKey) { + return null; + } + + const primary = normalizedKey; + + const modifiers = new Set(); + if (event.metaKey) modifiers.add("meta"); + if (event.ctrlKey) modifiers.add("ctrl"); + if (event.altKey) modifiers.add("alt"); + if (event.shiftKey) modifiers.add("shift"); + + const canonical = formatHotkeyString(modifiers, primary); + return canonicalizeHotkeyForPlatform(canonical, platform); +} + +export function isTerminalReservedHotkey(keys: string): boolean { + const canonical = canonicalizeHotkey(keys); + if (!canonical) return false; + return TERMINAL_RESERVED_CHORDS.has(canonical); +} + +export function isTerminalReservedEvent(event: KeyboardEventLike): boolean { + for (const reserved of TERMINAL_RESERVED_CHORDS) { + if (matchesHotkeyEvent(event, reserved)) return true; + } + return false; +} + +export function isOsReservedHotkey( + keys: string, + platform: HotkeyPlatform, +): boolean { + const canonical = canonicalizeHotkey(keys); + if (!canonical) return false; + return OS_RESERVED_CHORDS[platform].includes(canonical); +} + +/** + * Checks if a hotkey includes a primary modifier (ctrl or meta). + * App hotkeys must include ctrl or meta to avoid conflicts with terminal input + * and to ensure they work when the terminal is focused. + */ +export function hasPrimaryModifier(keys: string): boolean { + const parsed = parseHotkeyString(keys); + return parsed.modifiers.has("ctrl") || parsed.modifiers.has("meta"); +} + +export function deriveNonMacDefault(keys: string | null): string | null { + if (!keys) return null; + const canonical = canonicalizeHotkey(keys); + if (!canonical) return null; + const parsed = parseHotkeyString(canonical); + if (!parsed.key) return null; + const modifiers = new Set(parsed.modifiers); + const hadMeta = modifiers.delete("meta"); + if (!hadMeta) { + return formatHotkeyString(modifiers, parsed.key); + } + modifiers.add("ctrl"); + modifiers.add("shift"); + if (parsed.modifiers.has("shift")) { + modifiers.add("alt"); + } + return formatHotkeyString(modifiers, parsed.key); +} + +function defineHotkey(def: { + keys: string | null; label: string; - /** Category for grouping in the modal */ category: HotkeyCategory; - /** Optional description for more detail */ description?: string; + defaults?: Partial>; + isHidden?: boolean; +}): HotkeyDefinition { + const darwin = def.keys; + const win32 = def.defaults?.win32 ?? deriveNonMacDefault(darwin); + const linux = def.defaults?.linux ?? deriveNonMacDefault(darwin); + return { + label: def.label, + category: def.category, + description: def.description, + defaults: { + darwin, + win32: win32 ?? null, + linux: linux ?? null, + }, + isHidden: def.isHidden, + }; } -/** - * All hotkey definitions for the desktop app. - * Keys use react-hotkeys-hook format (meta = Cmd on Mac, Ctrl on Windows/Linux) - */ export const HOTKEYS = { // Workspace - switch with ⌘+1-9 - JUMP_TO_WORKSPACE_1: hotkey({ + JUMP_TO_WORKSPACE_1: defineHotkey({ keys: "meta+1", label: "Switch to Workspace 1", category: "Workspace", }), - JUMP_TO_WORKSPACE_2: hotkey({ + JUMP_TO_WORKSPACE_2: defineHotkey({ keys: "meta+2", label: "Switch to Workspace 2", category: "Workspace", }), - JUMP_TO_WORKSPACE_3: hotkey({ + JUMP_TO_WORKSPACE_3: defineHotkey({ keys: "meta+3", label: "Switch to Workspace 3", category: "Workspace", }), - JUMP_TO_WORKSPACE_4: hotkey({ + JUMP_TO_WORKSPACE_4: defineHotkey({ keys: "meta+4", label: "Switch to Workspace 4", category: "Workspace", }), - JUMP_TO_WORKSPACE_5: hotkey({ + JUMP_TO_WORKSPACE_5: defineHotkey({ keys: "meta+5", label: "Switch to Workspace 5", category: "Workspace", }), - JUMP_TO_WORKSPACE_6: hotkey({ + JUMP_TO_WORKSPACE_6: defineHotkey({ keys: "meta+6", label: "Switch to Workspace 6", category: "Workspace", }), - JUMP_TO_WORKSPACE_7: hotkey({ + JUMP_TO_WORKSPACE_7: defineHotkey({ keys: "meta+7", label: "Switch to Workspace 7", category: "Workspace", }), - JUMP_TO_WORKSPACE_8: hotkey({ + JUMP_TO_WORKSPACE_8: defineHotkey({ keys: "meta+8", label: "Switch to Workspace 8", category: "Workspace", }), - JUMP_TO_WORKSPACE_9: hotkey({ + JUMP_TO_WORKSPACE_9: defineHotkey({ keys: "meta+9", label: "Switch to Workspace 9", category: "Workspace", }), - PREV_WORKSPACE: hotkey({ + PREV_WORKSPACE: defineHotkey({ keys: "meta+left", label: "Previous Workspace", category: "Workspace", }), - NEXT_WORKSPACE: hotkey({ + NEXT_WORKSPACE: defineHotkey({ keys: "meta+right", label: "Next Workspace", category: "Workspace", }), // Layout - TOGGLE_SIDEBAR: hotkey({ + TOGGLE_SIDEBAR: defineHotkey({ keys: "meta+b", label: "Toggle Sidebar", category: "Layout", }), - SPLIT_RIGHT: hotkey({ + SPLIT_RIGHT: defineHotkey({ keys: "meta+d", label: "Split Right", category: "Layout", description: "Split the current pane to the right", }), - SPLIT_DOWN: hotkey({ + SPLIT_DOWN: defineHotkey({ keys: "meta+shift+d", label: "Split Down", category: "Layout", description: "Split the current pane downward", }), - SPLIT_AUTO: hotkey({ + SPLIT_AUTO: defineHotkey({ keys: "meta+e", label: "Split Pane Auto", category: "Layout", @@ -146,44 +446,44 @@ export const HOTKEYS = { }), // Terminal - FIND_IN_TERMINAL: hotkey({ + FIND_IN_TERMINAL: defineHotkey({ keys: "meta+f", label: "Find in Terminal", category: "Terminal", description: "Search text in the active terminal", }), - NEW_TERMINAL: hotkey({ + NEW_TERMINAL: defineHotkey({ keys: "meta+t", label: "New Terminal", category: "Terminal", }), - CLOSE_TERMINAL: hotkey({ + CLOSE_TERMINAL: defineHotkey({ keys: "meta+w", label: "Close Terminal", category: "Terminal", }), - CLEAR_TERMINAL: hotkey({ + CLEAR_TERMINAL: defineHotkey({ keys: "meta+k", label: "Clear Terminal", category: "Terminal", }), - PREV_TERMINAL: hotkey({ + PREV_TERMINAL: defineHotkey({ keys: "meta+up", label: "Previous Terminal", category: "Terminal", }), - NEXT_TERMINAL: hotkey({ + NEXT_TERMINAL: defineHotkey({ keys: "meta+down", label: "Next Terminal", category: "Terminal", }), - PREV_PANE: hotkey({ + PREV_PANE: defineHotkey({ keys: "meta+alt+left", label: "Previous Pane", category: "Terminal", description: "Focus the previous pane in the current tab", }), - NEXT_PANE: hotkey({ + NEXT_PANE: defineHotkey({ keys: "meta+alt+right", label: "Next Pane", category: "Terminal", @@ -191,19 +491,19 @@ export const HOTKEYS = { }), // Workspace creation - NEW_WORKSPACE: hotkey({ + NEW_WORKSPACE: defineHotkey({ keys: "meta+n", label: "New Workspace", category: "Workspace", description: "Open the new workspace modal", }), - QUICK_CREATE_WORKSPACE: hotkey({ + QUICK_CREATE_WORKSPACE: defineHotkey({ keys: "meta+shift+n", label: "Quick Create Workspace", category: "Workspace", description: "Quickly create a workspace in the current project", }), - OPEN_PROJECT: hotkey({ + OPEN_PROJECT: defineHotkey({ keys: "meta+shift+o", label: "Open Project", category: "Workspace", @@ -211,43 +511,48 @@ export const HOTKEYS = { }), // Window - NEW_WINDOW: hotkey({ - keys: "meta+alt+n", + NEW_WINDOW: defineHotkey({ + keys: null, label: "New Window", category: "Window", + isHidden: true, }), - CLOSE_WINDOW: hotkey({ + CLOSE_WINDOW: defineHotkey({ keys: "meta+shift+w", label: "Close Window", category: "Window", }), - OPEN_IN_APP: hotkey({ + OPEN_IN_APP: defineHotkey({ keys: "meta+o", label: "Open in App", category: "Window", description: "Open workspace in external app (Cursor, VS Code, etc.)", }), + COPY_PATH: defineHotkey({ + keys: "meta+shift+c", + label: "Copy Path", + category: "Window", + description: "Copy the workspace path to the clipboard", + }), // Help - SHOW_HOTKEYS: hotkey({ + SHOW_HOTKEYS: defineHotkey({ keys: "meta+slash", label: "Show Keyboard Shortcuts", category: "Help", }), -} as const satisfies Record; - -export type HotkeyId = keyof typeof HOTKEYS; +} as const satisfies Record; -export type HotkeyWithDisplay = HotkeyDefinition & { display: string[] }; +export function getVisibleHotkeys(): HotkeyId[] { + return (Object.keys(HOTKEYS) as HotkeyId[]).filter( + (id) => !HOTKEYS[id].isHidden, + ); +} -/** - * Get all hotkeys grouped by category for display purposes. - */ -export function getHotkeysByCategory(): Record< - HotkeyCategory, - HotkeyWithDisplay[] -> { - const grouped: Record = { +export function getHotkeysByCategory(options?: { + includeHidden?: boolean; +}): Record { + const grouped: Record = { Workspace: [], Layout: [], Terminal: [], @@ -255,77 +560,178 @@ export function getHotkeysByCategory(): Record< Help: [], }; - for (const hotkey of Object.values(HOTKEYS)) { - grouped[hotkey.category].push(hotkey); + for (const [id, hotkey] of Object.entries(HOTKEYS)) { + if (!options?.includeHidden && hotkey.isHidden) continue; + grouped[hotkey.category].push({ id: id as HotkeyId, ...hotkey }); } return grouped; } -/** - * Check if a keyboard event matches a hotkey string like "meta+shift+d" - */ -function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boolean { - const parts = hotkeyString.toLowerCase().split("+"); - - const requiresMeta = parts.includes("meta"); - const requiresShift = parts.includes("shift"); - const requiresAlt = parts.includes("alt"); - const requiresCtrl = parts.includes("ctrl"); - - // Get the actual key (last part that's not a modifier) - const key = parts.find((p) => !["meta", "shift", "alt", "ctrl"].includes(p)); - - if (!key) return false; +export function getDefaultHotkey( + id: HotkeyId, + platform: HotkeyPlatform, +): string | null { + return HOTKEYS[id].defaults[platform]; +} - const hasMeta = event.metaKey; - const hasShift = event.shiftKey; - const hasAlt = event.altKey; - const hasCtrl = event.ctrlKey; +export function getEffectiveHotkey( + id: HotkeyId, + overrides: Partial>, + platform: HotkeyPlatform, +): string | null { + if (overrides[id] !== undefined) return overrides[id] ?? null; + return getDefaultHotkey(id, platform); +} - if (requiresMeta !== hasMeta) return false; - if (requiresShift !== hasShift) return false; - if (requiresAlt !== hasAlt) return false; - if (requiresCtrl !== hasCtrl) return false; +export function getEffectiveHotkeysMap( + overrides: Partial>, + platform: HotkeyPlatform, +): Record { + const map = {} as Record; + for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { + map[id] = getEffectiveHotkey(id, overrides, platform); + } + return map; +} - // Match the key - const eventKey = event.key.toLowerCase(); - const eventCode = event.code.toLowerCase(); +export function buildOverridesFromBindings( + bindings: Partial>, + platform: HotkeyPlatform, +): Partial> { + const overrides: Partial> = {}; + for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { + if (!(id in bindings)) continue; + const value = bindings[id]; + if (value === undefined) continue; + const canonical = + value === null ? null : canonicalizeHotkeyForPlatform(value, platform); + if (canonical === null && value !== null) { + continue; + } + // App hotkeys must include ctrl or meta to work in terminal + if (canonical !== null && !hasPrimaryModifier(canonical)) { + continue; + } + const defaultValue = getDefaultHotkey(id, platform); + if (canonical === defaultValue) continue; + overrides[id] = canonical; + } + return overrides; +} - // Arrow keys - if (key === "up" && eventKey === "arrowup") return true; - if (key === "down" && eventKey === "arrowdown") return true; - if (key === "left" && eventKey === "arrowleft") return true; - if (key === "right" && eventKey === "arrowright") return true; +export function normalizeBindingsWithDefaults( + bindings: Partial>, + platform: HotkeyPlatform, +): Record { + const map = getEffectiveHotkeysMap({}, platform); + for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { + if (!(id in bindings)) continue; + const value = bindings[id]; + if (value === undefined) continue; + if (value === null) { + map[id] = null; + continue; + } + const canonical = canonicalizeHotkeyForPlatform(value, platform); + if (canonical) { + map[id] = canonical; + } + } + return map; +} - // Special characters - check both key and code (code is more reliable with modifiers) - if ( - (key === "/" || key === "slash") && - (eventKey === "/" || eventCode === "slash") - ) - return true; +export function createDefaultHotkeysState(): HotkeysState { + return { + version: HOTKEYS_STATE_VERSION, + byPlatform: { + darwin: {}, + win32: {}, + linux: {}, + }, + }; +} - // Direct match (letters, numbers) - if (eventKey === key) return true; +export function createHotkeysExport( + hotkeysState: HotkeysState, +): HotkeysExportFile { + return { + schemaVersion: HOTKEYS_STATE_VERSION, + exportedAt: new Date().toISOString(), + app: "@superset/desktop", + hotkeys: { + darwin: getEffectiveHotkeysMap(hotkeysState.byPlatform.darwin, "darwin"), + win32: getEffectiveHotkeysMap(hotkeysState.byPlatform.win32, "win32"), + linux: getEffectiveHotkeysMap(hotkeysState.byPlatform.linux, "linux"), + }, + }; +} - return false; +export function buildHotkeysStateFromExport( + exportFile: HotkeysExportFile, +): HotkeysState { + return { + version: HOTKEYS_STATE_VERSION, + byPlatform: { + darwin: buildOverridesFromBindings( + exportFile.hotkeys.darwin ?? {}, + "darwin", + ), + win32: buildOverridesFromBindings( + exportFile.hotkeys.win32 ?? {}, + "win32", + ), + linux: buildOverridesFromBindings( + exportFile.hotkeys.linux ?? {}, + "linux", + ), + }, + }; } -/** - * Find which hotkey ID matches the keyboard event, if any - */ -function findMatchingHotkey(event: KeyboardEvent): HotkeyId | null { - for (const [id, hotkey] of Object.entries(HOTKEYS)) { - if (matchesHotkey(event, hotkey.keys)) { - return id as HotkeyId; +export function getHotkeysSummary(bindings: Record): { + assigned: number; + disabled: number; +} { + let assigned = 0; + let disabled = 0; + for (const id of Object.keys(bindings) as HotkeyId[]) { + const value = bindings[id]; + if (value === null) { + disabled += 1; + } else { + assigned += 1; } } - return null; + return { assigned, disabled }; } -/** - * Check if an event matches any app hotkey (used by terminal to forward events) - */ -export function isAppHotkey(event: KeyboardEvent): boolean { - return findMatchingHotkey(event) !== null; +export function toElectronAccelerator( + keys: string | null, + platform: HotkeyPlatform, +): string | null { + if (!keys) return null; + const canonical = canonicalizeHotkey(keys); + if (!canonical) return null; + if (platform !== "darwin" && canonical.includes("meta+")) return null; + + const { modifiers, key } = parseHotkeyString(canonical); + if (!key) return null; + + const modifierTokens = MODIFIER_ORDER.filter((modifier) => + modifiers.has(modifier), + ).map((modifier) => { + if (modifier === "meta") return "Command"; + if (modifier === "ctrl") return "Ctrl"; + if (modifier === "alt") return "Alt"; + return "Shift"; + }); + + const mappedKey = + ELECTRON_KEY_MAP[key] ?? + (key.length === 1 + ? key.toUpperCase() + : `${key.charAt(0).toUpperCase()}${key.slice(1)}`); + + return [...modifierTokens, mappedKey].join("+"); } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000000..d450cc8460c --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "isolated" # Prevent hoisting from resolving `path-scurry@2` to `lru-cache@6` (missing `LRUCache`), which breaks the `@superset/desktop` postinstall native rebuild.