mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
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 { getIsElectronRuntime } from "@/constants/layout";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
parseHostWorkspaceRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
import {
|
|
||||||
type MessageInputKeyboardActionKind,
|
|
||||||
type KeyboardShortcutPayload,
|
|
||||||
} from "@/keyboard/actions";
|
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import {
|
import {
|
||||||
type ChordState,
|
type ChordState,
|
||||||
@@ -20,26 +11,23 @@ import {
|
|||||||
buildEffectiveBindings,
|
buildEffectiveBindings,
|
||||||
} from "@/keyboard/keyboard-shortcuts";
|
} from "@/keyboard/keyboard-shortcuts";
|
||||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||||
|
import {
|
||||||
|
routeKeyboardShortcut,
|
||||||
|
type ShortcutAction,
|
||||||
|
type ShortcutCallbackName,
|
||||||
|
} from "@/keyboard/route-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
|
|
||||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
import {
|
import {
|
||||||
getLastNavigationWorkspaceRouteSelection,
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
getNavigationActiveWorkspaceSelection,
|
getNavigationActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} 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({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -82,241 +70,57 @@ export function useKeyboardShortcuts({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToWorkspaceShortcut = (index: number): boolean => {
|
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
|
const targetEl =
|
||||||
if (!target) {
|
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||||
return false;
|
(target instanceof HTMLElement ? target : null);
|
||||||
}
|
const active = document.activeElement;
|
||||||
|
const activeEl = active instanceof HTMLElement ? active : null;
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||||
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 openProjectPicker = (): boolean => {
|
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||||
void openProjectPickerAction();
|
"toggle-agent-list": toggleAgentList,
|
||||||
return true;
|
"toggle-both-sidebars": toggleBothSidebars,
|
||||||
|
"toggle-focus-mode": toggleFocusMode,
|
||||||
|
"cycle-theme": cycleTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
|
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
|
||||||
switch (kind) {
|
switch (action.kind) {
|
||||||
case "focus":
|
case "none":
|
||||||
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:
|
|
||||||
return false;
|
return false;
|
||||||
}
|
case "dispatch":
|
||||||
};
|
return keyboardActionDispatcher.dispatch(action.action);
|
||||||
const handleDispatchOnlyAction = (action: string): boolean | null => {
|
case "navigate-workspace":
|
||||||
switch (action) {
|
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||||
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();
|
|
||||||
return true;
|
return true;
|
||||||
case "settings.toggle":
|
case "router-replace":
|
||||||
return handleSettingsToggle();
|
router.replace(action.route);
|
||||||
case "sidebar.toggle.both":
|
|
||||||
if (toggleBothSidebars) {
|
|
||||||
toggleBothSidebars();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "view.toggle.focus":
|
case "router-back":
|
||||||
if (toggleFocusMode) {
|
router.back();
|
||||||
toggleFocusMode();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "theme.cycle":
|
case "router-push":
|
||||||
if (cycleTheme) {
|
router.push(action.route);
|
||||||
cycleTheme();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "command-center.toggle":
|
case "open-project-picker":
|
||||||
return handleCommandCenterToggle(input.event);
|
void openProjectPickerAction();
|
||||||
case "shortcuts.dialog.toggle": {
|
return true;
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
case "callback":
|
||||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
callbacksByName[action.name]?.();
|
||||||
|
return true;
|
||||||
|
case "command-center-toggle": {
|
||||||
|
if (action.nextOpen) {
|
||||||
|
captureCommandCenterFocusRestore(event);
|
||||||
|
}
|
||||||
|
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
default:
|
case "shortcuts-dialog-toggle":
|
||||||
return false;
|
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -384,11 +188,20 @@ export function useKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handled = handleAction({
|
const shortcutAction = routeKeyboardShortcut(
|
||||||
action: result.match.action,
|
{ action: result.match.action, payload: result.match.payload },
|
||||||
payload: result.match.payload,
|
{
|
||||||
event,
|
pathname,
|
||||||
});
|
isMobile,
|
||||||
|
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||||
|
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
|
||||||
|
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
|
||||||
|
commandCenterOpen: store.commandCenterOpen,
|
||||||
|
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handled = performShortcutAction(shortcutAction, event);
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
357
packages/app/src/keyboard/route-shortcut.test.ts
Normal file
357
packages/app/src/keyboard/route-shortcut.test.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
routeKeyboardShortcut,
|
||||||
|
type ShortcutAction,
|
||||||
|
type ShortcutRoutingContext,
|
||||||
|
} from "./route-shortcut";
|
||||||
|
|
||||||
|
const SIDEBAR_TARGETS = [
|
||||||
|
{ 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" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRoutingContext {
|
||||||
|
return {
|
||||||
|
pathname: "/h/srv/workspace/ws-2",
|
||||||
|
isMobile: false,
|
||||||
|
sidebarShortcutTargets: SIDEBAR_TARGETS,
|
||||||
|
navigationActiveWorkspace: null,
|
||||||
|
lastNavigationWorkspaceRoute: null,
|
||||||
|
commandCenterOpen: false,
|
||||||
|
shortcutsDialogOpen: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||||
|
it.each([
|
||||||
|
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||||
|
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||||
|
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
|
||||||
|
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||||
|
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
|
||||||
|
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
|
||||||
|
["sidebar.toggle.right", { id: "sidebar.toggle.right", scope: "sidebar" }],
|
||||||
|
["workspace.pane.split.right", { id: "workspace.pane.split.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.split.down", { id: "workspace.pane.split.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.left", { id: "workspace.pane.focus.left", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.right", { id: "workspace.pane.focus.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.up", { id: "workspace.pane.focus.up", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.down", { id: "workspace.pane.focus.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.left", { id: "workspace.pane.move-tab.left", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.right", { id: "workspace.pane.move-tab.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }],
|
||||||
|
])("%s → dispatch %j", (action, expected) => {
|
||||||
|
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: expected,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
|
||||||
|
it("forwards index payloads to the workspace.tab.navigate-index dispatch", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.index", payload: { index: 3 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id: "workspace.tab.navigate-index", scope: "workspace", index: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when index payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.tab.navigate.index", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards delta payloads to the workspace.tab.navigate-relative dispatch", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.relative", payload: { delta: -1 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when delta payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.relative", payload: null },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.navigate.index", () => {
|
||||||
|
it("navigates to the sidebar target at index-1", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 4 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-4",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the target index is out of range", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 99 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the index payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.navigate.index", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when there are no sidebar targets", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 1 } },
|
||||||
|
makeCtx({ sidebarShortcutTargets: [] }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
|
||||||
|
it("uses the retained navigation workspace selection over a stale pathname", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/h/srv/workspace/ws-2",
|
||||||
|
navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-4" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the pathname workspace when no retained selection exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: -1 } },
|
||||||
|
makeCtx({ pathname: "/h/srv/workspace/ws-3", navigationActiveWorkspace: null }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps from the last target forward to the first", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({ navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-5" } }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when there are no sidebar targets", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({ sidebarShortcutTargets: [] }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the delta payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.navigate.relative", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first target when the current workspace is not in the sidebar", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
navigationActiveWorkspace: { serverId: "other", workspaceId: "ws-x" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — message-input.action", () => {
|
||||||
|
it.each([
|
||||||
|
["focus", "message-input.focus"],
|
||||||
|
["send", "message-input.send"],
|
||||||
|
["dictation-toggle", "message-input.dictation-toggle"],
|
||||||
|
["dictation-cancel", "message-input.dictation-cancel"],
|
||||||
|
["dictation-confirm", "message-input.dictation-confirm"],
|
||||||
|
["voice-toggle", "message-input.voice-toggle"],
|
||||||
|
["voice-mute-toggle", "message-input.voice-mute-toggle"],
|
||||||
|
] as const)("kind=%s → dispatch %s", (kind, id) => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id, scope: "message-input" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none for unsupported message-input kinds (queue)", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "message-input.action", payload: { kind: "queue" } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when kind is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — settings.toggle", () => {
|
||||||
|
it("pushes to the settings root when not currently in settings", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({ pathname: "/h/srv/workspace/ws-2" }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces to the retained workspace route when leaving settings on desktop", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
isMobile: false,
|
||||||
|
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "router-replace",
|
||||||
|
route: "/h/srv/workspace/ws-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to router.back() when no retained workspace exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-back" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
isMobile: true,
|
||||||
|
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-back" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — callbacks and pickers", () => {
|
||||||
|
it.each([
|
||||||
|
["sidebar.toggle.left", "toggle-agent-list"],
|
||||||
|
["sidebar.toggle.both", "toggle-both-sidebars"],
|
||||||
|
["view.toggle.focus", "toggle-focus-mode"],
|
||||||
|
["theme.cycle", "cycle-theme"],
|
||||||
|
] as const)("%s → callback %s", (action, name) => {
|
||||||
|
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({
|
||||||
|
kind: "callback",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent.new → open-project-picker", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "agent.new", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "open-project-picker" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — toggle dialogs", () => {
|
||||||
|
it("opens the command center when closed", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "command-center.toggle", payload: null },
|
||||||
|
makeCtx({ commandCenterOpen: false }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the command center when open", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "command-center.toggle", payload: null },
|
||||||
|
makeCtx({ commandCenterOpen: true }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the shortcuts dialog", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "shortcuts.dialog.toggle", payload: null },
|
||||||
|
makeCtx({ shortcutsDialogOpen: false }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "shortcuts.dialog.toggle", payload: null },
|
||||||
|
makeCtx({ shortcutsDialogOpen: true }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — unknown actions", () => {
|
||||||
|
it("returns none for unknown action ids", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "totally.made.up", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
217
packages/app/src/keyboard/route-shortcut.ts
Normal file
217
packages/app/src/keyboard/route-shortcut.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
buildSettingsRoute,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
import {
|
||||||
|
getRelativeSidebarShortcutTarget,
|
||||||
|
type SidebarShortcutWorkspaceTarget,
|
||||||
|
} from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
|
export interface ShortcutRoutingContext {
|
||||||
|
pathname: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
|
||||||
|
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
|
||||||
|
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
|
||||||
|
commandCenterOpen: boolean;
|
||||||
|
shortcutsDialogOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutRoutingInput {
|
||||||
|
action: string;
|
||||||
|
payload: KeyboardShortcutPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShortcutCallbackName =
|
||||||
|
| "toggle-agent-list"
|
||||||
|
| "toggle-both-sidebars"
|
||||||
|
| "toggle-focus-mode"
|
||||||
|
| "cycle-theme";
|
||||||
|
|
||||||
|
export type ShortcutAction =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "dispatch"; action: KeyboardActionDefinition }
|
||||||
|
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
|
||||||
|
| { kind: "router-replace"; route: string }
|
||||||
|
| { kind: "router-back" }
|
||||||
|
| { kind: "router-push"; route: string }
|
||||||
|
| { kind: "open-project-picker" }
|
||||||
|
| { kind: "callback"; name: ShortcutCallbackName }
|
||||||
|
| { kind: "command-center-toggle"; nextOpen: boolean }
|
||||||
|
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
||||||
|
|
||||||
|
const NONE: ShortcutAction = { kind: "none" };
|
||||||
|
|
||||||
|
// Action ids whose routing is a no-payload pass-through to the dispatcher.
|
||||||
|
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||||
|
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||||
|
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||||
|
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
|
||||||
|
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||||
|
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
|
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
|
||||||
|
"sidebar.toggle.right": { id: "sidebar.toggle.right", scope: "sidebar" },
|
||||||
|
"workspace.pane.split.right": { id: "workspace.pane.split.right", scope: "workspace" },
|
||||||
|
"workspace.pane.split.down": { id: "workspace.pane.split.down", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.left": { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.right": { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.up": { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.down": { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.left": { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.right": { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||||
|
"workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIMPLE_CALLBACKS: Record<string, ShortcutCallbackName> = {
|
||||||
|
"sidebar.toggle.left": "toggle-agent-list",
|
||||||
|
"sidebar.toggle.both": "toggle-both-sidebars",
|
||||||
|
"view.toggle.focus": "toggle-focus-mode",
|
||||||
|
"theme.cycle": "cycle-theme",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGE_INPUT_DISPATCH: Record<
|
||||||
|
MessageInputKeyboardActionKind,
|
||||||
|
KeyboardActionDefinition | null
|
||||||
|
> = {
|
||||||
|
focus: { id: "message-input.focus", scope: "message-input" },
|
||||||
|
send: { id: "message-input.send", scope: "message-input" },
|
||||||
|
"dictation-toggle": { id: "message-input.dictation-toggle", scope: "message-input" },
|
||||||
|
"dictation-cancel": { id: "message-input.dictation-cancel", scope: "message-input" },
|
||||||
|
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
|
||||||
|
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
|
||||||
|
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
|
||||||
|
queue: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasPayloadKey<K extends "index" | "delta" | "kind">(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
key: K,
|
||||||
|
): payload is Extract<KeyboardShortcutPayload, Record<K, unknown>> {
|
||||||
|
return !!payload && typeof payload === "object" && key in payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(action: KeyboardActionDefinition): ShortcutAction {
|
||||||
|
return { kind: "dispatch", action };
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceTabNavigateIndex(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "index")) return NONE;
|
||||||
|
return dispatch({
|
||||||
|
id: "workspace.tab.navigate-index",
|
||||||
|
scope: "workspace",
|
||||||
|
index: payload.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceTabNavigateRelative(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "delta")) return NONE;
|
||||||
|
return dispatch({
|
||||||
|
id: "workspace.tab.navigate-relative",
|
||||||
|
scope: "workspace",
|
||||||
|
delta: payload.delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceNavigateIndex(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "index")) return NONE;
|
||||||
|
const target = ctx.sidebarShortcutTargets[payload.index - 1] ?? null;
|
||||||
|
if (!target) return NONE;
|
||||||
|
return {
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: target.serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceNavigateRelative(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "delta")) return NONE;
|
||||||
|
if (ctx.sidebarShortcutTargets.length === 0) return NONE;
|
||||||
|
|
||||||
|
const currentWorkspace =
|
||||||
|
ctx.navigationActiveWorkspace ?? parseHostWorkspaceRouteFromPathname(ctx.pathname);
|
||||||
|
const target = getRelativeSidebarShortcutTarget({
|
||||||
|
targets: ctx.sidebarShortcutTargets,
|
||||||
|
currentTarget: currentWorkspace
|
||||||
|
? { serverId: currentWorkspace.serverId, workspaceId: currentWorkspace.workspaceId }
|
||||||
|
: null,
|
||||||
|
delta: payload.delta,
|
||||||
|
});
|
||||||
|
if (!target) return NONE;
|
||||||
|
return {
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: target.serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeMessageInputAction(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "kind")) return NONE;
|
||||||
|
const action = MESSAGE_INPUT_DISPATCH[payload.kind];
|
||||||
|
if (!action) return NONE;
|
||||||
|
return dispatch(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
|
||||||
|
if (!ctx.pathname.startsWith("/settings")) {
|
||||||
|
return { kind: "router-push", route: buildSettingsRoute() };
|
||||||
|
}
|
||||||
|
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
|
||||||
|
return {
|
||||||
|
kind: "router-replace",
|
||||||
|
route: buildHostWorkspaceRoute(
|
||||||
|
ctx.lastNavigationWorkspaceRoute.serverId,
|
||||||
|
ctx.lastNavigationWorkspaceRoute.workspaceId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "router-back" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeKeyboardShortcut(
|
||||||
|
input: ShortcutRoutingInput,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
const passthrough = PASSTHROUGH_DISPATCH[input.action];
|
||||||
|
if (passthrough) {
|
||||||
|
return dispatch(passthrough);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = SIMPLE_CALLBACKS[input.action];
|
||||||
|
if (callback) {
|
||||||
|
return { kind: "callback", name: callback };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (input.action) {
|
||||||
|
case "workspace.tab.navigate.index":
|
||||||
|
return routeWorkspaceTabNavigateIndex(input.payload);
|
||||||
|
case "workspace.tab.navigate.relative":
|
||||||
|
return routeWorkspaceTabNavigateRelative(input.payload);
|
||||||
|
case "workspace.navigate.index":
|
||||||
|
return routeWorkspaceNavigateIndex(input.payload, ctx);
|
||||||
|
case "workspace.navigate.relative":
|
||||||
|
return routeWorkspaceNavigateRelative(input.payload, ctx);
|
||||||
|
case "message-input.action":
|
||||||
|
return routeMessageInputAction(input.payload);
|
||||||
|
case "agent.new":
|
||||||
|
return { kind: "open-project-picker" };
|
||||||
|
case "settings.toggle":
|
||||||
|
return routeSettingsToggle(ctx);
|
||||||
|
case "command-center.toggle":
|
||||||
|
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||||
|
case "shortcuts.dialog.toggle":
|
||||||
|
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
|
||||||
|
default:
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user