Files
kandev/apps/web/hooks/use-editor-keybinds.ts
T
José Almeida e626d33a7c fix(web): make toggle-sidebar shortcut work on every route (#1283)
* fix(web): make toggle-sidebar shortcut work on every route

TOGGLE_SIDEBAR (Cmd/Ctrl+B) was handled only inside useEditorKeybinds,
mounted just by the dockview desktop layout, so it was dead on Kanban,
the task list, Office, Settings, etc. Lift it to a global app-root
listener (useAppShortcuts, mounted in GlobalCommands) that honors user
overrides via getShortcut and keeps the isEditableTarget guard, and drop
the now-duplicate branch from useEditorKeybinds. Adds a hook unit test
and a Playwright e2e covering Kanban, Settings, and the dockview session.

* fix(web): hydrate userSettings into the root store on settings routes

Each StateProvider creates its own store; the settings/general page mounts
a nested one that the root-mounted GlobalCommands/AppSidebar can't see. The
settings layout hydrated the root store but omitted userSettings, so
override-driven global shortcuts (TOGGLE_SIDEBAR, Quick Chat) were dead on
every /settings route. Hydrate userSettings there too, matching the
kanban/tasks/office/github routes.

* test(web): cover the macOS Cmd path; note the settings-layout catch trade-off

Address review feedback: add a metaKey (Cmd) variant to the useAppShortcuts
test so both ctrlOrCmd branches are exercised, and document that the settings
layout's outer catch intentionally drops userSettings overrides in the
already-degraded error path.
2026-06-06 23:09:12 +01:00

176 lines
5.5 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useAppStoreApi } from "@/components/state-provider";
import { createUserShell } from "@/lib/api/domains/user-shell-api";
import { matchesShortcut } from "@/lib/keyboard/utils";
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import type { DockviewApi } from "dockview-react";
function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) {
const activePanel = api.activePanel;
if (!activePanel) return;
const panels = activePanel.group.panels;
if (panels.length <= 1) return;
const currentIndex = panels.findIndex((p) => p.id === activePanel.id);
if (currentIndex === -1) return;
e.preventDefault();
e.stopPropagation();
const direction = e.code === "BracketLeft" ? -1 : 1;
const nextIndex = (currentIndex + direction + panels.length) % panels.length;
panels[nextIndex].api.setActive();
}
function handleTerminalToggle(
e: KeyboardEvent,
api: DockviewApi,
previousPanelIdRef: React.MutableRefObject<string | null>,
getEnvironmentId: () => string | null,
getTaskID: () => string | null,
) {
e.preventDefault();
e.stopPropagation();
const activePanel = api.activePanel;
const isTerminalFocused = activePanel?.id.startsWith("terminal-") ?? false;
if (isTerminalFocused) {
const prevId = previousPanelIdRef.current;
const target = prevId ? api.getPanel(prevId) : api.getPanel("chat");
if (target) target.api.setActive();
previousPanelIdRef.current = null;
return;
}
if (activePanel) {
previousPanelIdRef.current = activePanel.id;
}
const terminalPanel = api.panels.find((p) => p.id.startsWith("terminal-"));
if (terminalPanel) {
terminalPanel.api.setActive();
return;
}
const environmentId = getEnvironmentId();
if (!environmentId) return;
const taskID = getTaskID();
createUserShell(environmentId, { taskId: taskID ?? undefined })
.then((result) => {
const title = result.displayName ?? result.label ?? "Terminal";
useDockviewStore
.getState()
.addTerminalPanel(result.terminalId, undefined, environmentId, taskID ?? undefined, title);
})
.catch((err) => {
console.warn("Failed to create terminal shell:", err);
});
}
function handleBottomTerminal(
e: KeyboardEvent,
appStore: ReturnType<typeof useAppStoreApi>,
previousFocusRef: React.MutableRefObject<Element | null>,
overrides: StoredShortcutOverrides | undefined,
): boolean {
// Toggle bottom terminal panel (default: Cmd/Ctrl+J).
// Note: no isEditableTarget guard here. The default binding is not a standard text
// editing shortcut, and we must preventDefault even when an xterm textarea
// is focused — otherwise the un-prevented event causes escape-sequence
// artifacts (e.g. trailing "R" from cursor-position reports during resize).
if (matchesShortcut(e, getShortcut("BOTTOM_TERMINAL", overrides))) {
e.preventDefault();
e.stopPropagation();
const isOpen = appStore.getState().bottomTerminal.isOpen;
if (!isOpen) {
// Opening: save the currently focused element to restore later
previousFocusRef.current = document.activeElement;
}
appStore.getState().toggleBottomTerminal();
if (isOpen) {
// Closing: restore focus to the previously focused element
const prev = previousFocusRef.current;
if (prev instanceof HTMLElement && prev.isConnected) {
prev.focus({ preventScroll: true });
} else {
// Fallback: focus the chat panel
const api = useDockviewStore.getState().api;
const chatPanel = api?.getPanel("chat");
if (chatPanel) chatPanel.api.setActive();
}
previousFocusRef.current = null;
}
return true;
}
return false;
}
/**
* Global editor keybinds for dockview:
* - Cmd/Ctrl+Shift+[ / ] — navigate prev/next tab in active group
* - Ctrl+` — toggle terminal focus
* - Cmd/Ctrl+J — toggle bottom terminal panel
*
* Note: `TOGGLE_SIDEBAR` (Cmd/Ctrl+B) is handled app-wide by `useAppShortcuts`
* (mounted near the root), not here, so it works on every route.
*/
export function useEditorKeybinds() {
const previousPanelIdRef = useRef<string | null>(null);
const previousFocusRef = useRef<Element | null>(null);
const appStore = useAppStoreApi();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const api = useDockviewStore.getState().api;
if (!api) return;
const overrides = appStore.getState().userSettings.keyboardShortcuts;
const isTabNav =
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
(e.code === "BracketLeft" || e.code === "BracketRight");
if (isTabNav) {
handleTabNavigation(e, api);
return;
}
const isTerminalToggle = e.ctrlKey && !e.metaKey && !e.shiftKey && e.code === "Backquote";
if (isTerminalToggle) {
handleTerminalToggle(
e,
api,
previousPanelIdRef,
() => {
const state = appStore.getState();
const sid = state.tasks.activeSessionId;
return sid ? (state.environmentIdBySessionId[sid] ?? null) : null;
},
() => appStore.getState().tasks?.activeTaskId ?? null,
);
return;
}
handleBottomTerminal(e, appStore, previousFocusRef, overrides);
};
// Use capture phase so we receive events before xterm.js
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [appStore]);
}