347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { useEffect } from "react";
|
|
import { Platform } from "react-native";
|
|
import { usePathname } from "expo-router";
|
|
import { getIsTauri } from "@/constants/layout";
|
|
import { useHosts } from "@/runtime/host-runtime";
|
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
|
import {
|
|
parseHostAgentRouteFromPathname,
|
|
parseServerIdFromPathname,
|
|
parseHostWorkspaceRouteFromPathname,
|
|
} from "@/utils/host-routes";
|
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
|
import {
|
|
type MessageInputKeyboardActionKind,
|
|
type KeyboardShortcutPayload,
|
|
} from "@/keyboard/actions";
|
|
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
|
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
|
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
|
|
|
export function useKeyboardShortcuts({
|
|
enabled,
|
|
isMobile,
|
|
toggleAgentList,
|
|
selectedAgentId,
|
|
toggleFileExplorer,
|
|
}: {
|
|
enabled: boolean;
|
|
isMobile: boolean;
|
|
toggleAgentList: () => void;
|
|
selectedAgentId?: string;
|
|
toggleFileExplorer?: () => void;
|
|
}) {
|
|
const pathname = usePathname();
|
|
const hosts = useHosts();
|
|
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
|
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
|
const activeServerId =
|
|
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
|
hosts[0]?.serverId ??
|
|
null;
|
|
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
if (Platform.OS !== "web") return;
|
|
if (isMobile) return;
|
|
|
|
const isTauri = getIsTauri();
|
|
const isMac = getShortcutOs() === "mac";
|
|
|
|
const shouldHandle = () => {
|
|
if (typeof document === "undefined") return false;
|
|
if (document.visibilityState !== "visible") return false;
|
|
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);
|
|
return true;
|
|
};
|
|
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
|
const state = useKeyboardShortcutsStore.getState();
|
|
const targets = state.visibleWorkspaceTargets;
|
|
if (targets.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
|
if (!workspaceRoute) {
|
|
const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null;
|
|
if (!fallback) {
|
|
return false;
|
|
}
|
|
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
|
|
return true;
|
|
}
|
|
|
|
const currentIndex = targets.findIndex(
|
|
(target) =>
|
|
target.serverId === workspaceRoute.serverId &&
|
|
target.workspaceId === workspaceRoute.workspaceId
|
|
);
|
|
const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
|
|
const nextIndex = (fromIndex + delta + targets.length) % targets.length;
|
|
const target = targets[nextIndex] ?? null;
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
navigateToWorkspace(target.serverId, target.workspaceId);
|
|
return true;
|
|
};
|
|
|
|
const openProjectPicker = (): boolean => {
|
|
void openProjectPickerAction();
|
|
return true;
|
|
};
|
|
|
|
const dispatchMessageInputAction = (
|
|
kind: MessageInputKeyboardActionKind
|
|
): boolean => {
|
|
switch (kind) {
|
|
case "focus":
|
|
return keyboardActionDispatcher.dispatch({
|
|
id: "message-input.focus",
|
|
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 "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;
|
|
}
|
|
};
|
|
const handleAction = (input: {
|
|
action: string;
|
|
payload: KeyboardShortcutPayload;
|
|
event: KeyboardEvent;
|
|
}): boolean => {
|
|
switch (input.action) {
|
|
case "agent.new":
|
|
return openProjectPicker();
|
|
case "workspace.tab.new":
|
|
return keyboardActionDispatcher.dispatch({
|
|
id: "workspace.tab.new",
|
|
scope: "workspace",
|
|
});
|
|
case "workspace.tab.close.current":
|
|
return keyboardActionDispatcher.dispatch({
|
|
id: "workspace.tab.close-current",
|
|
scope: "workspace",
|
|
});
|
|
case "workspace.tab.navigate.index":
|
|
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
|
return false;
|
|
}
|
|
return keyboardActionDispatcher.dispatch({
|
|
id: "workspace.tab.navigate-index",
|
|
scope: "workspace",
|
|
index: input.payload.index,
|
|
});
|
|
case "workspace.tab.navigate.relative":
|
|
if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) {
|
|
return false;
|
|
}
|
|
return keyboardActionDispatcher.dispatch({
|
|
id: "workspace.tab.navigate-relative",
|
|
scope: "workspace",
|
|
delta: input.payload.delta,
|
|
});
|
|
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: input.action,
|
|
scope: "workspace",
|
|
});
|
|
case "workspace.navigate.index":
|
|
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
|
return false;
|
|
}
|
|
return navigateToWorkspaceShortcut(input.payload.index);
|
|
case "workspace.navigate.relative":
|
|
if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) {
|
|
return false;
|
|
}
|
|
return navigateRelativeWorkspace(input.payload.delta);
|
|
case "sidebar.toggle.left":
|
|
toggleAgentList();
|
|
return true;
|
|
case "sidebar.toggle.right":
|
|
if (!toggleFileExplorer) {
|
|
return false;
|
|
}
|
|
if (
|
|
!canToggleFileExplorerShortcut({
|
|
selectedAgentId,
|
|
pathname,
|
|
toggleFileExplorer,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
toggleFileExplorer();
|
|
return true;
|
|
case "command-center.toggle": {
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
if (!store.commandCenterOpen) {
|
|
const target =
|
|
input.event.target instanceof Element ? (input.event.target as Element) : 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;
|
|
}
|
|
case "shortcuts.dialog.toggle": {
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
|
return true;
|
|
}
|
|
case "message-input.action":
|
|
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
|
return false;
|
|
}
|
|
return dispatchMessageInputAction(input.payload.kind);
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (!shouldHandle()) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key ?? "";
|
|
if (key === "Alt" && !event.shiftKey) {
|
|
useKeyboardShortcutsStore.getState().setAltDown(true);
|
|
}
|
|
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
|
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
|
}
|
|
if (key === "Shift") {
|
|
const state = useKeyboardShortcutsStore.getState();
|
|
if (state.altDown || state.cmdOrCtrlDown) {
|
|
state.resetModifiers();
|
|
}
|
|
}
|
|
|
|
const store = useKeyboardShortcutsStore.getState();
|
|
const focusScope = resolveKeyboardFocusScope({
|
|
target: event.target,
|
|
commandCenterOpen: store.commandCenterOpen,
|
|
});
|
|
const match = resolveKeyboardShortcut({
|
|
event,
|
|
context: {
|
|
isMac,
|
|
isTauri,
|
|
focusScope,
|
|
commandCenterOpen: store.commandCenterOpen,
|
|
hasSelectedAgent: canToggleFileExplorerShortcut({
|
|
selectedAgentId,
|
|
pathname,
|
|
toggleFileExplorer,
|
|
}),
|
|
},
|
|
});
|
|
if (!match) {
|
|
return;
|
|
}
|
|
|
|
const handled = handleAction({
|
|
action: match.action,
|
|
payload: match.payload,
|
|
event,
|
|
});
|
|
if (!handled) {
|
|
return;
|
|
}
|
|
|
|
if (match.preventDefault) {
|
|
event.preventDefault();
|
|
}
|
|
if (match.stopPropagation) {
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
const key = event.key ?? "";
|
|
if (key === "Alt") {
|
|
useKeyboardShortcutsStore.getState().setAltDown(false);
|
|
}
|
|
if (isTauri && (key === "Meta" || key === "Control")) {
|
|
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
|
}
|
|
};
|
|
|
|
const handleBlurOrHide = () => {
|
|
resetModifiers();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
window.addEventListener("keyup", handleKeyUp, true);
|
|
window.addEventListener("blur", handleBlurOrHide);
|
|
document.addEventListener("visibilitychange", handleBlurOrHide);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
window.removeEventListener("keyup", handleKeyUp, true);
|
|
window.removeEventListener("blur", handleBlurOrHide);
|
|
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
|
};
|
|
}, [
|
|
enabled,
|
|
isMobile,
|
|
openProjectPickerAction,
|
|
pathname,
|
|
resetModifiers,
|
|
selectedAgentId,
|
|
toggleAgentList,
|
|
toggleFileExplorer,
|
|
]);
|
|
}
|