* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
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 { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
|
import {
|
|
type ChordState,
|
|
type KeyboardShortcutInput,
|
|
resolveKeyboardShortcut,
|
|
buildEffectiveBindings,
|
|
getWorkspaceIndexJumpModifierKey,
|
|
} from "@/keyboard/keyboard-shortcuts";
|
|
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
|
import {
|
|
buildBrowserKeyboardPolicy,
|
|
parseBrowserShortcutInput,
|
|
shouldPublishBrowserShortcutPolicy,
|
|
} from "@/desktop/browser/shortcuts";
|
|
import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions";
|
|
import {
|
|
routeKeyboardShortcut,
|
|
type ShortcutAction,
|
|
type ShortcutCallbackName,
|
|
} from "@/keyboard/route-shortcut";
|
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
|
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 {
|
|
type ActiveWorkspaceSelection,
|
|
navigateToLastWorkspace,
|
|
useActiveWorkspaceSelection,
|
|
} from "@/stores/navigation-active-workspace-store";
|
|
|
|
export function useKeyboardShortcuts({
|
|
enabled,
|
|
isMobile,
|
|
isWorkspaceFocusModeEnabled,
|
|
toggleAgentList,
|
|
toggleBothSidebars,
|
|
exitFocusMode,
|
|
cycleTheme,
|
|
}: {
|
|
enabled: boolean;
|
|
isMobile: boolean;
|
|
isWorkspaceFocusModeEnabled: boolean;
|
|
toggleAgentList: () => void;
|
|
toggleBothSidebars?: () => void;
|
|
exitFocusMode: () => void;
|
|
cycleTheme?: () => void;
|
|
}) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
|
const { overrides } = useKeyboardShortcutOverrides();
|
|
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
|
const isDesktopApp = getIsElectronRuntime();
|
|
const isMac = getShortcutOs() === "mac";
|
|
const chordStateRef = useRef<ChordState>({
|
|
candidateIndices: [],
|
|
step: 0,
|
|
timeoutId: null,
|
|
});
|
|
const openProjectPickerAction = useOpenAddProject();
|
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
|
|
|
const publishBrowserShortcutPolicy = useCallback(
|
|
(chordState?: ChordState) => {
|
|
const policy =
|
|
enabled && !isMobile
|
|
? buildBrowserKeyboardPolicy({
|
|
bindings,
|
|
chordState,
|
|
isMac,
|
|
isDesktop: isDesktopApp,
|
|
})
|
|
: { menuPrefixes: [], prefixes: [] };
|
|
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
|
},
|
|
[bindings, enabled, isDesktopApp, isMac, isMobile],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeWorkspaceSelection) {
|
|
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
|
|
}
|
|
}, [activeWorkspaceSelection]);
|
|
|
|
useEffect(() => {
|
|
if (!isDesktopApp) {
|
|
return;
|
|
}
|
|
|
|
publishBrowserShortcutPolicy();
|
|
}, [isDesktopApp, publishBrowserShortcutPolicy]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
if (isNative) return;
|
|
if (isMobile) return;
|
|
|
|
// Only the modifier that actually performs the workspace-index jump on this
|
|
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
|
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
|
// to drive badge visibility, so we set the flag matching this runtime.
|
|
const badgeModifierKey = getWorkspaceIndexJumpModifierKey({ isMac, isDesktop: isDesktopApp });
|
|
const setBadgeModifierDown = (down: boolean) => {
|
|
const state = useKeyboardShortcutsStore.getState();
|
|
if (isDesktopApp) {
|
|
state.setCmdOrCtrlDown(down);
|
|
} else {
|
|
state.setAltDown(down);
|
|
}
|
|
};
|
|
|
|
const shouldHandle = () => {
|
|
if (typeof document === "undefined") return false;
|
|
if (document.visibilityState !== "visible") return false;
|
|
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 callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
|
"toggle-agent-list": toggleAgentList,
|
|
"toggle-both-sidebars": toggleBothSidebars,
|
|
"cycle-theme": cycleTheme,
|
|
};
|
|
|
|
const performShortcutAction = (
|
|
action: ShortcutAction,
|
|
event: KeyboardEvent | null,
|
|
browserFocusRestoreElement: HTMLElement | null = null,
|
|
): boolean => {
|
|
switch (action.kind) {
|
|
case "none":
|
|
return false;
|
|
case "dispatch":
|
|
return keyboardActionDispatcher.dispatch(action.action);
|
|
case "navigate-workspace":
|
|
keyboardWorkspaceSelectionRef.current = {
|
|
serverId: action.serverId,
|
|
workspaceId: action.workspaceId,
|
|
};
|
|
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
|
return true;
|
|
case "navigate-last-workspace":
|
|
return navigateToLastWorkspace();
|
|
case "router-replace":
|
|
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
|
return true;
|
|
case "router-back":
|
|
router.back();
|
|
return true;
|
|
case "router-push":
|
|
router.push(action.route as Parameters<typeof router.push>[0]);
|
|
return true;
|
|
case "open-project-picker":
|
|
void openProjectPickerAction();
|
|
return true;
|
|
case "callback":
|
|
callbacksByName[action.name]?.();
|
|
return true;
|
|
case "command-center-toggle": {
|
|
if (action.nextOpen) {
|
|
if (event) {
|
|
captureCommandCenterFocusRestore(event);
|
|
} else {
|
|
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
|
}
|
|
}
|
|
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
|
|
return true;
|
|
}
|
|
case "shortcuts-dialog-toggle":
|
|
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const routeAndPerformShortcut = (input: {
|
|
action: string;
|
|
payload: KeyboardShortcutPayload;
|
|
domEvent: KeyboardEvent | null;
|
|
browserFocusRestoreElement?: HTMLElement | null;
|
|
}): boolean => {
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
const shortcutAction = routeKeyboardShortcut(
|
|
{ action: input.action, payload: input.payload },
|
|
{
|
|
pathname,
|
|
isMobile,
|
|
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
|
navigationActiveWorkspace:
|
|
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
|
commandCenterOpen: store.commandCenterOpen,
|
|
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
|
},
|
|
);
|
|
const handled = performShortcutAction(
|
|
shortcutAction,
|
|
input.domEvent,
|
|
input.browserFocusRestoreElement,
|
|
);
|
|
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
|
exitFocusMode();
|
|
}
|
|
return handled;
|
|
};
|
|
|
|
const resolveAndPerformShortcut = (input: {
|
|
event: KeyboardShortcutInput;
|
|
focusScope: KeyboardFocusScope;
|
|
domEvent: KeyboardEvent | null;
|
|
browserFocusRestoreElement?: HTMLElement | null;
|
|
}) => {
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
const previousChordState = chordStateRef.current;
|
|
const result = resolveKeyboardShortcut({
|
|
event: input.event,
|
|
context: {
|
|
isMac,
|
|
isDesktop: isDesktopApp,
|
|
focusScope: input.focusScope,
|
|
commandCenterOpen: store.commandCenterOpen,
|
|
},
|
|
chordState: chordStateRef.current,
|
|
onChordReset: () => {
|
|
chordStateRef.current = {
|
|
candidateIndices: [],
|
|
step: 0,
|
|
timeoutId: null,
|
|
};
|
|
publishBrowserShortcutPolicy();
|
|
},
|
|
bindings,
|
|
});
|
|
|
|
chordStateRef.current = result.nextChordState;
|
|
if (
|
|
shouldPublishBrowserShortcutPolicy({
|
|
isBrowserInput: "browserId" in input.event,
|
|
previousChordState,
|
|
nextChordState: result.nextChordState,
|
|
})
|
|
) {
|
|
publishBrowserShortcutPolicy(result.nextChordState);
|
|
}
|
|
|
|
if (result.preventDefault && input.domEvent) {
|
|
input.domEvent.preventDefault();
|
|
input.domEvent.stopPropagation();
|
|
}
|
|
|
|
if (!result.match) {
|
|
return;
|
|
}
|
|
|
|
const handled = routeAndPerformShortcut({
|
|
action: result.match.action,
|
|
payload: result.match.payload,
|
|
domEvent: input.domEvent,
|
|
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
|
});
|
|
if (!handled || !input.domEvent) {
|
|
return;
|
|
}
|
|
|
|
if (result.match.preventDefault) {
|
|
input.domEvent.preventDefault();
|
|
}
|
|
if (result.match.stopPropagation) {
|
|
input.domEvent.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (!shouldHandle()) {
|
|
return;
|
|
}
|
|
|
|
// During IME composition, Enter confirms the candidate selection and must
|
|
// not route through global shortcuts like message send.
|
|
if (isImeComposingKeyboardEvent(event)) {
|
|
return;
|
|
}
|
|
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
if (store.capturingShortcut) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key ?? "";
|
|
if (key === badgeModifierKey && !event.shiftKey) {
|
|
setBadgeModifierDown(true);
|
|
}
|
|
if (key === "Shift") {
|
|
const state = useKeyboardShortcutsStore.getState();
|
|
if (state.altDown || state.cmdOrCtrlDown) {
|
|
state.resetModifiers();
|
|
}
|
|
}
|
|
|
|
const focusScope = resolveKeyboardFocusScope({
|
|
target: event.target,
|
|
commandCenterOpen: store.commandCenterOpen,
|
|
});
|
|
resolveAndPerformShortcut({
|
|
event,
|
|
focusScope,
|
|
domEvent: event,
|
|
});
|
|
};
|
|
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
const key = event.key ?? "";
|
|
if (key === badgeModifierKey) {
|
|
setBadgeModifierDown(false);
|
|
}
|
|
};
|
|
|
|
const handleBlurOrHide = () => {
|
|
resetModifiers();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
window.addEventListener("keyup", handleKeyUp, true);
|
|
window.addEventListener("blur", handleBlurOrHide);
|
|
document.addEventListener("visibilitychange", handleBlurOrHide);
|
|
|
|
const browserShortcutSubscription = isElectronRuntime()
|
|
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
|
|
const input = parseBrowserShortcutInput(payload);
|
|
if (!input) {
|
|
return;
|
|
}
|
|
resolveAndPerformShortcut({
|
|
event: input,
|
|
focusScope: "browser",
|
|
domEvent: null,
|
|
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
|
});
|
|
})
|
|
: null;
|
|
return () => {
|
|
if (chordStateRef.current.timeoutId !== null) {
|
|
clearTimeout(chordStateRef.current.timeoutId);
|
|
chordStateRef.current = {
|
|
candidateIndices: [],
|
|
step: 0,
|
|
timeoutId: null,
|
|
};
|
|
}
|
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
window.removeEventListener("keyup", handleKeyUp, true);
|
|
window.removeEventListener("blur", handleBlurOrHide);
|
|
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
|
if (typeof browserShortcutSubscription === "function") {
|
|
browserShortcutSubscription();
|
|
} else {
|
|
void browserShortcutSubscription?.then((dispose) => dispose());
|
|
}
|
|
};
|
|
}, [
|
|
bindings,
|
|
cycleTheme,
|
|
enabled,
|
|
exitFocusMode,
|
|
activeWorkspaceSelection,
|
|
isDesktopApp,
|
|
isMac,
|
|
isMobile,
|
|
isWorkspaceFocusModeEnabled,
|
|
openProjectPickerAction,
|
|
pathname,
|
|
publishBrowserShortcutPolicy,
|
|
resetModifiers,
|
|
router,
|
|
toggleAgentList,
|
|
toggleBothSidebars,
|
|
]);
|
|
}
|