From 8526e14930ab80fb233841ae17e85b726c50dc95 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 20 Nov 2025 17:16:21 -0800 Subject: [PATCH] Remove custom hotkeys handler in favor of react-hotkeys-hook --- apps/desktop/package.json | 1 + .../src/renderer/hooks/useGlobalShortcuts.ts | 162 ----------- .../src/renderer/lib/keyboard-shortcuts.ts | 97 ------- apps/desktop/src/renderer/lib/shortcuts.ts | 268 ------------------ .../components/TopBar/WorkspaceTabs/index.tsx | 20 ++ .../main/components/WorkspaceView/index.tsx | 63 ++++ .../src/renderer/screens/main/index.tsx | 28 +- bun.lock | 3 + 8 files changed, 113 insertions(+), 529 deletions(-) delete mode 100644 apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts delete mode 100644 apps/desktop/src/renderer/lib/keyboard-shortcuts.ts delete mode 100644 apps/desktop/src/renderer/lib/shortcuts.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c736f02d3cb..19d841a2757 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -57,6 +57,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.1.1", + "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", diff --git a/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts b/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts deleted file mode 100644 index aab69090626..00000000000 --- a/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { createShortcutHandler } from "../lib/keyboard-shortcuts"; -import { - createSplitPaneShortcuts, - createTabShortcuts, - createWorkspaceShortcuts, -} from "../lib/shortcuts"; -import { useSidebarStore } from "../stores/sidebar-state"; -import { - useActiveTabIds, - useAddTab, - useRemoveTab, - useSetActiveTab, - useSplitTabHorizontal, - useSplitTabVertical, - useTabs, -} from "../stores/tabs"; -import { trpc } from "../lib/trpc"; -import { useSetActiveWorkspace } from "../react-query/workspaces"; - -function findWorkspaceIndex( - workspaces: Array<{ id: string }>, - id: string | null, -) { - if (!id) return -1; - return workspaces.findIndex((w) => w.id === id); -} - -function findTabIndex(tabs: Array<{ id: string }>, id: string | null) { - if (!id) return -1; - return tabs.findIndex((t) => t.id === id); -} - -export function useGlobalShortcuts() { - const { data: workspaces = [] } = trpc.workspaces.getAll.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const setActiveWorkspace = useSetActiveWorkspace(); - const { toggleSidebar } = useSidebarStore(); - const tabs = useTabs(); - const activeTabIds = useActiveTabIds(); - const setActiveTab = useSetActiveTab(); - const addTab = useAddTab(); - const removeTab = useRemoveTab(); - const splitTabVertical = useSplitTabVertical(); - const splitTabHorizontal = useSplitTabHorizontal(); - - const activeWorkspaceId = activeWorkspace?.id; - - const workspaceTabs = useMemo(() => { - if (!activeWorkspaceId) return []; - return tabs.filter( - (t) => t.workspaceId === activeWorkspaceId && !t.parentId, - ); - }, [tabs, activeWorkspaceId]); - - const activeTabId = activeWorkspaceId - ? activeTabIds[activeWorkspaceId] - : null; - - useEffect(() => { - const workspaceHandlers = { - switchToPrevWorkspace: () => { - if (!activeWorkspaceId) return; - const index = findWorkspaceIndex(workspaces, activeWorkspaceId); - if (index > 0) { - setActiveWorkspace.mutate({ id: workspaces[index - 1].id }); - } - }, - switchToNextWorkspace: () => { - if (!activeWorkspaceId) return; - const index = findWorkspaceIndex(workspaces, activeWorkspaceId); - if (index < workspaces.length - 1) { - setActiveWorkspace.mutate({ id: workspaces[index + 1].id }); - } - }, - toggleSidebar, - splitVertical: () => { - if (activeWorkspaceId) { - splitTabVertical(activeWorkspaceId); - } - }, - splitHorizontal: () => { - if (activeWorkspaceId) { - splitTabHorizontal(activeWorkspaceId); - } - }, - }; - - const tabHandlers = { - switchToPrevTab: () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = findTabIndex(workspaceTabs, activeTabId); - if (index > 0) { - setActiveTab(activeWorkspaceId, workspaceTabs[index - 1].id); - } - }, - switchToNextTab: () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = findTabIndex(workspaceTabs, activeTabId); - if (index < workspaceTabs.length - 1) { - setActiveTab(activeWorkspaceId, workspaceTabs[index + 1].id); - } - }, - newTab: () => { - if (activeWorkspaceId) { - addTab(activeWorkspaceId); - } - }, - closeTab: () => { - if (activeTabId) { - removeTab(activeTabId); - } - }, - reopenClosedTab: () => { - console.log("Reopen closed tab"); - }, - jumpToTab: (index: number) => { - if (!activeWorkspaceId) return; - const targetTab = workspaceTabs[index - 1]; - if (targetTab) { - setActiveTab(activeWorkspaceId, targetTab.id); - } - }, - }; - - const splitPaneHandlers = { - focusPaneLeft: () => console.log("Focus pane left"), - focusPaneRight: () => console.log("Focus pane right"), - focusPaneUp: () => console.log("Focus pane up"), - focusPaneDown: () => console.log("Focus pane down"), - }; - - const workspaceShortcuts = createWorkspaceShortcuts(workspaceHandlers); - const tabShortcuts = createTabShortcuts(tabHandlers); - const splitPaneShortcuts = createSplitPaneShortcuts(splitPaneHandlers); - - const allShortcuts = [ - ...workspaceShortcuts.shortcuts, - ...tabShortcuts.shortcuts, - ...splitPaneShortcuts.shortcuts, - ]; - - const handleKeyDown = createShortcutHandler(allShortcuts); - window.addEventListener("keydown", handleKeyDown); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - workspaces, - activeWorkspaceId, - workspaceTabs, - activeTabId, - setActiveWorkspace, - toggleSidebar, - setActiveTab, - addTab, - removeTab, - splitTabVertical, - splitTabHorizontal, - ]); -} diff --git a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts deleted file mode 100644 index 3bad6ab1f8c..00000000000 --- a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Keyboard shortcuts module - * Central place for all keyboard shortcut definitions and handling - */ - -export type ModifierKey = "meta" | "ctrl" | "alt" | "shift"; - -export interface KeyboardShortcut { - key: string; - modifiers?: ModifierKey[]; - description: string; - handler: (event: KeyboardEvent) => boolean; -} - -export interface KeyboardShortcutGroup { - name: string; - shortcuts: KeyboardShortcut[]; -} - -/** - * Check if event matches the shortcut definition - */ -export function matchesShortcut( - event: KeyboardEvent, - shortcut: KeyboardShortcut, -): boolean { - // Check key match (case insensitive) - if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) { - return false; - } - - // Check modifiers - const modifiers = shortcut.modifiers || []; - const hasCtrl = modifiers.includes("ctrl"); - const hasMeta = modifiers.includes("meta"); - const hasAlt = modifiers.includes("alt"); - const hasShift = modifiers.includes("shift"); - - return ( - event.ctrlKey === hasCtrl && - event.metaKey === hasMeta && - event.altKey === hasAlt && - event.shiftKey === hasShift - ); -} - -/** - * Create a keyboard event handler that processes multiple shortcuts - */ -export function createShortcutHandler(shortcuts: KeyboardShortcut[]) { - return (event: KeyboardEvent): boolean => { - for (const shortcut of shortcuts) { - if (matchesShortcut(event, shortcut)) { - const result = shortcut.handler(event); - // If handler returns false, prevent default and stop propagation - if (result === false) { - event.preventDefault(); - return false; - } - } - } - // Allow event to propagate normally - return true; - }; -} - -/** - * Format shortcut for display (e.g., "Cmd+K" or "Ctrl+Shift+P") - */ -export function formatShortcut(shortcut: KeyboardShortcut): string { - const modifiers = shortcut.modifiers || []; - const parts: string[] = []; - - // Use platform-specific display names - const isMac = navigator.platform.toLowerCase().includes("mac"); - - for (const mod of modifiers) { - switch (mod) { - case "meta": - parts.push(isMac ? "Cmd" : "Win"); - break; - case "ctrl": - parts.push("Ctrl"); - break; - case "alt": - parts.push(isMac ? "Opt" : "Alt"); - break; - case "shift": - parts.push("Shift"); - break; - } - } - - parts.push(shortcut.key.toUpperCase()); - - return parts.join("+"); -} diff --git a/apps/desktop/src/renderer/lib/shortcuts.ts b/apps/desktop/src/renderer/lib/shortcuts.ts deleted file mode 100644 index 52073affbb7..00000000000 --- a/apps/desktop/src/renderer/lib/shortcuts.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Arc-style keyboard shortcuts for Superset - */ - -import type { - KeyboardShortcut, - KeyboardShortcutGroup, -} from "./keyboard-shortcuts"; - -export interface ShortcutHandlers { - // Workspace management - switchToPrevWorkspace: () => void; - switchToNextWorkspace: () => void; - toggleSidebar: () => void; - splitHorizontal: () => void; - splitVertical: () => void; - - // Tab management - switchToPrevTab: () => void; - switchToNextTab: () => void; - newTab: () => void; - closeTab: () => void; - reopenClosedTab: () => void; - jumpToTab: (index: number) => void; - - // Split pane navigation - focusPaneLeft: () => void; - focusPaneRight: () => void; - focusPaneUp: () => void; - focusPaneDown: () => void; - - // Terminal specific - clearTerminal: () => void; - closeTerminal: () => void; -} - -export function createWorkspaceShortcuts( - handlers: Pick< - ShortcutHandlers, - | "switchToPrevWorkspace" - | "switchToNextWorkspace" - | "toggleSidebar" - | "splitVertical" - | "splitHorizontal" - >, -): KeyboardShortcutGroup { - return { - name: "Workspace Management", - shortcuts: [ - { - key: "ArrowLeft", - modifiers: ["meta", "alt"], - description: "Switch to previous workspace", - handler: (event) => { - event.preventDefault(); - handlers.switchToPrevWorkspace(); - return false; - }, - }, - { - key: "ArrowRight", - modifiers: ["meta", "alt"], - description: "Switch to next workspace", - handler: (event) => { - event.preventDefault(); - handlers.switchToNextWorkspace(); - return false; - }, - }, - { - key: "s", - modifiers: ["meta"], - description: "Toggle sidebar visibility", - handler: (event) => { - event.preventDefault(); - handlers.toggleSidebar(); - return false; - }, - }, - { - key: "d", - modifiers: ["meta"], - description: "Split window vertically", - handler: (event) => { - event.preventDefault(); - handlers.splitVertical(); - return false; - }, - }, - { - key: "d", - modifiers: ["meta", "shift"], - description: "Split window horizontally", - handler: (event) => { - event.preventDefault(); - handlers.splitHorizontal(); - return false; - }, - }, - ], - }; -} - -export function createTabShortcuts( - handlers: Pick< - ShortcutHandlers, - | "switchToPrevTab" - | "switchToNextTab" - | "newTab" - | "closeTab" - | "reopenClosedTab" - | "jumpToTab" - >, -): KeyboardShortcutGroup { - const shortcuts: KeyboardShortcut[] = [ - { - key: "ArrowUp", - modifiers: ["meta", "alt"], - description: "Switch to previous tab", - handler: (event) => { - event.preventDefault(); - handlers.switchToPrevTab(); - return false; - }, - }, - { - key: "ArrowDown", - modifiers: ["meta", "alt"], - description: "Switch to next tab", - handler: (event) => { - event.preventDefault(); - handlers.switchToNextTab(); - return false; - }, - }, - { - key: "t", - modifiers: ["meta"], - description: "Create new tab", - handler: (event) => { - event.preventDefault(); - handlers.newTab(); - return false; - }, - }, - { - key: "w", - modifiers: ["meta"], - description: "Close current tab", - handler: (event) => { - event.preventDefault(); - handlers.closeTab(); - return false; - }, - }, - { - key: "t", - modifiers: ["meta", "shift"], - description: "Reopen closed tab", - handler: (event) => { - event.preventDefault(); - handlers.reopenClosedTab(); - return false; - }, - }, - ]; - - for (let i = 1; i <= 9; i++) { - shortcuts.push({ - key: i.toString(), - modifiers: ["meta"], - description: `Jump to tab ${i}`, - handler: (event) => { - event.preventDefault(); - handlers.jumpToTab(i); - return false; - }, - }); - } - - return { - name: "Tab Management", - shortcuts, - }; -} - -export function createSplitPaneShortcuts( - handlers: Pick< - ShortcutHandlers, - "focusPaneLeft" | "focusPaneRight" | "focusPaneUp" | "focusPaneDown" - >, -): KeyboardShortcutGroup { - return { - name: "Split Pane Navigation", - shortcuts: [ - { - key: "ArrowLeft", - modifiers: ["meta", "alt"], - description: "Focus left pane", - handler: (event) => { - event.preventDefault(); - handlers.focusPaneLeft(); - return false; - }, - }, - { - key: "ArrowRight", - modifiers: ["meta", "alt"], - description: "Focus right pane", - handler: (event) => { - event.preventDefault(); - handlers.focusPaneRight(); - return false; - }, - }, - { - key: "ArrowUp", - modifiers: ["meta", "alt"], - description: "Focus upper pane", - handler: (event) => { - event.preventDefault(); - handlers.focusPaneUp(); - return false; - }, - }, - { - key: "ArrowDown", - modifiers: ["meta", "alt"], - description: "Focus lower pane", - handler: (event) => { - event.preventDefault(); - handlers.focusPaneDown(); - return false; - }, - }, - ], - }; -} - -export function createTerminalShortcuts( - handlers: Pick, -): KeyboardShortcutGroup { - return { - name: "Terminal", - shortcuts: [ - { - key: "k", - modifiers: ["meta"], - description: "Clear terminal (scrollback + screen)", - handler: (event) => { - event.preventDefault(); - handlers.clearTerminal(); - return false; - }, - }, - { - key: "w", - modifiers: ["meta"], - description: "Close current terminal", - handler: (event) => { - event.preventDefault(); - handlers.closeTerminal(); - return false; - }, - }, - ], - }; -} 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 9687f5f62da..3e8160eb87a 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,6 +1,8 @@ import { Separator } from "@superset/ui/separator"; import { Fragment, useEffect, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; import { WorkspaceDropdown } from "./WorkspaceDropdown"; import { WorkspaceItem } from "./WorkspaceItem"; @@ -12,6 +14,7 @@ export function WorkspacesTabs() { const { data: workspaces = [] } = trpc.workspaces.getAll.useQuery(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id || null; + const setActiveWorkspace = useSetActiveWorkspace(); const containerRef = useRef(null); const scrollRef = useRef(null); const [showStartFade, setShowStartFade] = useState(false); @@ -21,6 +24,23 @@ export function WorkspacesTabs() { null, ); + // Workspace switching shortcuts + useHotkeys('meta+alt+left', () => { + if (!activeWorkspaceId) return; + const index = workspaces.findIndex((w) => w.id === activeWorkspaceId); + if (index > 0) { + setActiveWorkspace.mutate({ id: workspaces[index - 1].id }); + } + }, [activeWorkspaceId, workspaces, setActiveWorkspace]); + + useHotkeys('meta+alt+right', () => { + if (!activeWorkspaceId) return; + const index = workspaces.findIndex((w) => w.id === activeWorkspaceId); + if (index < workspaces.length - 1) { + setActiveWorkspace.mutate({ id: workspaces[index + 1].id }); + } + }, [activeWorkspaceId, workspaces, setActiveWorkspace]); + useEffect(() => { const checkScroll = () => { if (!scrollRef.current) return; 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 1022d294f5b..d7c0a7571a4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,7 +1,70 @@ +import { useHotkeys } from "react-hotkeys-hook"; +import { trpc } from "renderer/lib/trpc"; +import { useAddTab, useActiveTabIds, useSetActiveTab, useRemoveTab, useTabs } from "renderer/stores"; +import { useMemo } from "react"; import { ContentView } from "./ContentView"; import { Sidebar } from "./Sidebar"; export function WorkspaceView() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + const allTabs = useTabs(); + const activeTabIds = useActiveTabIds(); + const addTab = useAddTab(); + const setActiveTab = useSetActiveTab(); + const removeTab = useRemoveTab(); + + const tabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter( + (tab) => tab.workspaceId === activeWorkspaceId && !tab.parentId, + ) + : [], + [activeWorkspaceId, allTabs], + ); + + const activeTabId = activeWorkspaceId ? activeTabIds[activeWorkspaceId] : null; + + // Tab management shortcuts - work even when sidebar is closed + useHotkeys('meta+t', () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }, [activeWorkspaceId, addTab]); + + useHotkeys('meta+w', () => { + if (activeTabId) { + removeTab(activeTabId); + } + }, [activeTabId, removeTab]); + + useHotkeys('meta+alt+up', () => { + 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]); + + useHotkeys('meta+alt+down', () => { + 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]); + + // Jump to tab by number (Cmd+1 through Cmd+9) + useHotkeys('meta+1,meta+2,meta+3,meta+4,meta+5,meta+6,meta+7,meta+8,meta+9', (_, handler) => { + if (!activeWorkspaceId) return; + const key = handler.keys?.join(''); + const num = key ? Number.parseInt(key, 10) : null; + if (num && tabs[num - 1]) { + setActiveTab(activeWorkspaceId, tabs[num - 1].id); + } + }, [activeWorkspaceId, tabs, setActiveTab]); + return (
diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index a01440d04bc..626fea9b5f3 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -1,5 +1,8 @@ import { DndProvider } from "react-dnd"; -import { useGlobalShortcuts } from "renderer/hooks/useGlobalShortcuts"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useSidebarStore } from "renderer/stores/sidebar-state"; +import { useSplitTabHorizontal, useSplitTabVertical } from "renderer/stores/tabs"; +import { trpc } from "renderer/lib/trpc"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -7,7 +10,28 @@ import { TopBar } from "./components/TopBar"; import { WorkspaceView } from "./components/WorkspaceView"; export function MainScreen() { - useGlobalShortcuts(); + const { toggleSidebar } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const splitTabVertical = useSplitTabVertical(); + const splitTabHorizontal = useSplitTabHorizontal(); + + const activeWorkspaceId = activeWorkspace?.id; + + // Sidebar toggle shortcut + useHotkeys('meta+s', toggleSidebar, [toggleSidebar]); + + // Split view shortcuts + useHotkeys('meta+d', () => { + if (activeWorkspaceId) { + splitTabVertical(activeWorkspaceId); + } + }, [activeWorkspaceId, splitTabVertical]); + + useHotkeys('meta+shift+d', () => { + if (activeWorkspaceId) { + splitTabHorizontal(activeWorkspaceId); + } + }, [activeWorkspaceId, splitTabHorizontal]); return ( diff --git a/bun.lock b/bun.lock index 2451ddbc2ee..519c4ae0803 100644 --- a/bun.lock +++ b/bun.lock @@ -106,6 +106,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.1.1", + "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", @@ -3100,6 +3101,8 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-hotkeys-hook": ["react-hotkeys-hook@5.2.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],