refactor(app): extract keyboard shortcut routing into a pure function (#718)
Replace 8-mock hook test (expo-router, layout, platform, navigation, 4 stores) with a pure unit test of the routing decision. The hook now reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches the resulting ShortcutAction — no behaviour change.
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
|
||||
navigateToWorkspaceMock: vi.fn(),
|
||||
routerMock: {
|
||||
back: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
pathState: {
|
||||
pathname: "/h/srv/workspace/ws-2",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
usePathname: () => pathState.pathname,
|
||||
useRouter: () => routerMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
getIsElectronRuntime: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/shortcut-platform", () => ({
|
||||
getShortcutOs: () => "mac",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-active-server-id", () => ({
|
||||
useActiveServerId: () => "srv",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||
useOpenProjectPicker: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
|
||||
useKeyboardShortcutOverrides: () => ({ overrides: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-workspace-navigation", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
}));
|
||||
|
||||
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
function Probe() {
|
||||
useKeyboardShortcuts({
|
||||
enabled: true,
|
||||
isMobile: false,
|
||||
toggleAgentList: vi.fn(),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useKeyboardShortcuts", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
routerMock.back.mockReset();
|
||||
routerMock.push.mockReset();
|
||||
routerMock.replace.mockReset();
|
||||
pathState.pathname = "/h/srv/workspace/ws-2";
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
useKeyboardShortcutsStore.setState({
|
||||
capturingShortcut: false,
|
||||
commandCenterOpen: false,
|
||||
sidebarShortcutWorkspaceTargets: [
|
||||
{ serverId: "srv", workspaceId: "ws-1" },
|
||||
{ serverId: "srv", workspaceId: "ws-2" },
|
||||
{ serverId: "srv", workspaceId: "ws-3" },
|
||||
{ serverId: "srv", workspaceId: "ws-4" },
|
||||
{ serverId: "srv", workspaceId: "ws-5" },
|
||||
],
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
});
|
||||
|
||||
it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
|
||||
activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<Probe />);
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "]",
|
||||
code: "BracketRight",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
|
||||
currentPathname: "/h/srv/workspace/ws-2",
|
||||
});
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatches Escape as an agent interrupt action", async () => {
|
||||
const handleInterrupt = vi.fn(() => true);
|
||||
const unregister = keyboardActionDispatcher.registerHandler({
|
||||
handlerId: "test-agent-interrupt",
|
||||
actions: ["agent.interrupt"],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleInterrupt,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<Probe />);
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(handleInterrupt).toHaveBeenCalledTimes(1);
|
||||
unregister();
|
||||
});
|
||||
|
||||
it("returns from desktop settings to the retained workspace without browser history back", async () => {
|
||||
syncNavigationActiveWorkspace({
|
||||
current: {
|
||||
getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
|
||||
},
|
||||
});
|
||||
pathState.pathname = "/settings/general";
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<Probe />);
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: ",",
|
||||
code: "Comma",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
|
||||
expect(routerMock.back).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,7 @@ import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildSettingsRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
} from "@/keyboard/actions";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
type ChordState,
|
||||
@@ -20,26 +11,23 @@ import {
|
||||
buildEffectiveBindings,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import {
|
||||
routeKeyboardShortcut,
|
||||
type ShortcutAction,
|
||||
type ShortcutCallbackName,
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
function hasPayloadKey<K extends string>(
|
||||
payload: KeyboardShortcutPayload,
|
||||
key: K,
|
||||
): payload is KeyboardShortcutPayload & Record<K, never> {
|
||||
return !!payload && typeof payload === "object" && key in payload;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
@@ -82,241 +70,57 @@ export function useKeyboardShortcuts({
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToWorkspaceShortcut = (index: number): boolean => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
};
|
||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
const targets = state.sidebarShortcutWorkspaceTargets;
|
||||
if (targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceRoute =
|
||||
getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
|
||||
const target = getRelativeSidebarShortcutTarget({
|
||||
targets,
|
||||
currentTarget: workspaceRoute
|
||||
? {
|
||||
serverId: workspaceRoute.serverId,
|
||||
workspaceId: workspaceRoute.workspaceId,
|
||||
}
|
||||
: null,
|
||||
delta,
|
||||
});
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const openProjectPicker = (): boolean => {
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"toggle-focus-mode": toggleFocusMode,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
|
||||
switch (kind) {
|
||||
case "focus":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "send":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.send",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-cancel":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-cancel",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-confirm":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-confirm",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "voice-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.voice-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "voice-mute-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.voice-mute-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
default:
|
||||
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleDispatchOnlyAction = (action: string): boolean | null => {
|
||||
switch (action) {
|
||||
case "agent.interrupt":
|
||||
return keyboardActionDispatcher.dispatch({ id: "agent.interrupt", scope: "global" });
|
||||
case "workspace.tab.new":
|
||||
return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", scope: "workspace" });
|
||||
case "worktree.archive":
|
||||
return keyboardActionDispatcher.dispatch({ id: "worktree.archive", scope: "sidebar" });
|
||||
case "worktree.new":
|
||||
return keyboardActionDispatcher.dispatch({ id: "worktree.new", scope: "sidebar" });
|
||||
case "workspace.terminal.new":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.terminal.new",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "workspace.tab.close.current":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.close-current",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "sidebar.toggle.right":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
});
|
||||
case "workspace.pane.split.right":
|
||||
case "workspace.pane.split.down":
|
||||
case "workspace.pane.focus.left":
|
||||
case "workspace.pane.focus.right":
|
||||
case "workspace.pane.focus.up":
|
||||
case "workspace.pane.focus.down":
|
||||
case "workspace.pane.move-tab.left":
|
||||
case "workspace.pane.move-tab.right":
|
||||
case "workspace.pane.move-tab.up":
|
||||
case "workspace.pane.move-tab.down":
|
||||
case "workspace.pane.close":
|
||||
return keyboardActionDispatcher.dispatch({ id: action, scope: "workspace" });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayloadAction = (
|
||||
action: string,
|
||||
payload: KeyboardShortcutPayload,
|
||||
): boolean | null => {
|
||||
switch (action) {
|
||||
case "workspace.tab.navigate.index":
|
||||
if (!hasPayloadKey(payload, "index")) return false;
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.navigate-index",
|
||||
scope: "workspace",
|
||||
index: payload.index,
|
||||
});
|
||||
case "workspace.tab.navigate.relative":
|
||||
if (!hasPayloadKey(payload, "delta")) return false;
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.navigate-relative",
|
||||
scope: "workspace",
|
||||
delta: payload.delta,
|
||||
});
|
||||
case "workspace.navigate.index":
|
||||
if (!hasPayloadKey(payload, "index")) return false;
|
||||
return navigateToWorkspaceShortcut(payload.index);
|
||||
case "workspace.navigate.relative":
|
||||
if (!hasPayloadKey(payload, "delta")) return false;
|
||||
return navigateRelativeWorkspace(payload.delta);
|
||||
case "message-input.action":
|
||||
if (!hasPayloadKey(payload, "kind")) return false;
|
||||
return dispatchMessageInputAction(payload.kind);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsToggle = (): boolean => {
|
||||
if (pathname.startsWith("/settings")) {
|
||||
if (!isMobile) {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
router.back();
|
||||
return true;
|
||||
}
|
||||
router.push(buildSettingsRoute());
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (!store.commandCenterOpen) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
}
|
||||
store.setCommandCenterOpen(!store.commandCenterOpen);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAction = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
event: KeyboardEvent;
|
||||
}): boolean => {
|
||||
const dispatchOnlyResult = handleDispatchOnlyAction(input.action);
|
||||
if (dispatchOnlyResult !== null) {
|
||||
return dispatchOnlyResult;
|
||||
}
|
||||
const payloadResult = handlePayloadAction(input.action, input.payload);
|
||||
if (payloadResult !== null) {
|
||||
return payloadResult;
|
||||
}
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return openProjectPicker();
|
||||
case "sidebar.toggle.left":
|
||||
toggleAgentList();
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
case "settings.toggle":
|
||||
return handleSettingsToggle();
|
||||
case "sidebar.toggle.both":
|
||||
if (toggleBothSidebars) {
|
||||
toggleBothSidebars();
|
||||
}
|
||||
case "router-replace":
|
||||
router.replace(action.route);
|
||||
return true;
|
||||
case "view.toggle.focus":
|
||||
if (toggleFocusMode) {
|
||||
toggleFocusMode();
|
||||
}
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "theme.cycle":
|
||||
if (cycleTheme) {
|
||||
cycleTheme();
|
||||
}
|
||||
case "router-push":
|
||||
router.push(action.route);
|
||||
return true;
|
||||
case "command-center.toggle":
|
||||
return handleCommandCenterToggle(input.event);
|
||||
case "shortcuts.dialog.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -384,11 +188,20 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
event,
|
||||
});
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: result.match.action, payload: result.match.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
|
||||
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const handled = performShortcutAction(shortcutAction, event);
|
||||
if (!handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user