Files
2026-08-28 15:51:18 +00:00

189 lines
6.8 KiB
TypeScript

"use client";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useRouter } from "@/lib/routing/client-router";
import { useTheme } from "@/components/theme/app-theme";
import { getThemeToggleLabelKey, getThemeToggleTarget } from "@/components/theme/theme-toggle";
import { IconSun, IconMoon, IconMessageCircle, IconSparkles } from "@tabler/icons-react";
import { useStaticDestinations } from "@/hooks/use-app-destinations";
import { PALETTE_NAVIGATION_GROUP_KEY } from "@/lib/navigation/surface-policy";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { searchKeywords } from "@/lib/commands/search-keywords";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { useAppShortcuts } from "@/hooks/use-app-shortcuts";
import { usePluginShortcuts } from "@/hooks/use-plugin-shortcuts";
import { useAppStore } from "@/components/state-provider";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import type { CommandItem } from "@/lib/commands/types";
import { SettingsDiscoveryCommands } from "@/components/settings-discovery-commands";
type PushFn = ReturnType<typeof useRouter>["push"];
// Catalog keys, not copy — safe at module scope (no `t()` call here). The
// palette groups by this resolved value, so every producer must use these.
const GROUP_NAVIGATION = PALETTE_NAVIGATION_GROUP_KEY;
const GROUP_ACTIONS = "common:commandGroupActions";
/**
* The Navigation group comes from the navigation manifest
* (`lib/navigation/core-destinations.ts`), so a destination cannot reach the palette
* without also being offered on the surfaces it declares. Command ids and copy
* live in each destination's `palette` block — they are stable API for tests and
* telemetry. The Settings group below stays hand-written: those are deep links
* into one destination, not destinations of their own.
*/
function useNavigationCommands(push: PushFn, t: TFunction): CommandItem[] {
// `useStaticDestinations`, not `useAppDestinations`: this component is mounted
// for the whole session, and the palette's one gated entry (GitHub) opts out of
// the availability check, so subscribing to integration polling here would add
// background requests for nothing.
const destinations = useStaticDestinations("palette");
// Cached on a value signature so the command array keeps its identity across
// unrelated re-renders — `useRegisterCommands` re-registers whenever the array
// changes. Same render-time cache pattern as `use-responsive-breakpoint.ts`;
// a `useMemo` cannot work here because the resolved list is rebuilt each render.
const group = t(GROUP_NAVIGATION);
const resolved = destinations.map((destination) => ({
destination,
keywords: destination.palette?.keywordsKey
? searchKeywords(t, destination.palette.keywordsKey)
: [],
}));
const signature = JSON.stringify(
resolved.map(({ destination, keywords }) => [
destination.id,
destination.palette?.id,
destination.href,
destination.label,
group,
keywords,
]),
);
const cacheRef = useRef<{
signature: string;
push: PushFn;
commands: CommandItem[];
} | null>(null);
if (
!cacheRef.current ||
cacheRef.current.signature !== signature ||
cacheRef.current.push !== push
) {
cacheRef.current = {
signature,
push,
commands: resolved.map(({ destination, keywords }) => {
const Icon = destination.icon;
return {
id: destination.palette?.id ?? `nav-${destination.id}`,
label: destination.label,
group,
icon: <Icon className="size-3.5" />,
keywords,
action: () => push(destination.href),
};
}),
};
}
return cacheRef.current.commands;
}
function buildThemeCommand(
resolvedTheme: string | undefined,
setTheme: (theme: string) => void,
t: TFunction,
): CommandItem {
const isDark = resolvedTheme === "dark";
const currentTheme = isDark ? "dark" : "light";
const destinationTheme = getThemeToggleTarget(currentTheme);
return {
id: "pref-theme",
label: t(getThemeToggleLabelKey(currentTheme)),
group: t("common:commandGroupPreferences"),
icon: isDark ? <IconSun className="size-3.5" /> : <IconMoon className="size-3.5" />,
keywords: searchKeywords(t, "common:commandThemeKeywords"),
action: () => setTheme(destinationTheme),
};
}
export function GlobalCommands() {
const { t } = useTranslation();
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const handleOpenQuickChat = useQuickChatLauncher(activeWorkspaceId, "chat", {
silentFocusReturn: false,
toggleWhenOpen: true,
});
const handleOpenConfigChat = useQuickChatLauncher(activeWorkspaceId, "config", {
silentFocusReturn: false,
});
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts);
const quickChatCommand: CommandItem = useMemo(
() => ({
id: "quick-chat",
label: t("common:commandQuickChat"),
group: t(GROUP_ACTIONS),
icon: <IconMessageCircle className="size-3.5" />,
keywords: searchKeywords(t, "common:commandQuickChatKeywords"),
shortcut: quickChatShortcut,
action: handleOpenQuickChat,
}),
[handleOpenQuickChat, quickChatShortcut, t],
);
const configChatCommand: CommandItem = useMemo(
() => ({
id: "config-chat",
label: t("common:configurationChat"),
group: t(GROUP_ACTIONS),
icon: <IconSparkles className="size-3.5" />,
keywords: searchKeywords(t, "common:commandConfigChatKeywords"),
action: handleOpenConfigChat,
}),
[handleOpenConfigChat, t],
);
const navigationCommands = useNavigationCommands(router.push, t);
const commands = useMemo<CommandItem[]>(
() => [
...navigationCommands,
buildThemeCommand(resolvedTheme, setTheme, t),
quickChatCommand,
configChatCommand,
],
[
navigationCommands,
router.push,
resolvedTheme,
setTheme,
quickChatCommand,
configChatCommand,
t,
],
);
useRegisterCommands(commands);
// Order matters: useAppShortcuts (core) must register its capture-phase
// keydown listener before the Quick Chat toggle and plugin shortcuts. This
// preserves core precedence, while the toggle still runs before xterm can
// consume a configured shortcut.
useAppShortcuts();
useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat, {
capture: true,
stopPropagation: true,
});
usePluginShortcuts();
return <SettingsDiscoveryCommands />;
}