1import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts";
2
3import { resolveShortcutCommand, type ShortcutMatchContext } from "../keybindings";
4import { isEditableFocused } from "../lib/editableFocus";
5import { isPreviewFocused } from "../lib/previewFocus";
6import { isTerminalFocused } from "../lib/terminalFocus";
7import { isModelPickerOpen } from "../modelPickerVisibility";
8import { appAtomRegistry } from "../rpc/atomRegistry";
9import { primaryServerKeybindingsAtom } from "../state/server";
10
11/** Return `false` to let the next scope handle the event. */
12export type CommandHandler = (event: KeyboardEvent) => boolean | void;
13
14export type CommandHandlers = Partial<Record<KeybindingCommand, CommandHandler>>;
15
16export interface CommandScopeOptions {
17 /** Read at event time; a scope whose guard fails is skipped. */
18 readonly when?: (event: KeyboardEvent) => boolean;
19 /** When-clause context the dispatcher cannot know (terminalOpen, previewOpen), read at event time. */
20 readonly context?: () => Partial<ShortcutMatchContext>;
21 readonly keybindings?: () => ResolvedKeybindingsConfig;
22 /** For shortcuts that are not keybinding commands (Escape, "/"); runs before command lookup. */
23 readonly onKeyDown?: CommandHandler;
24 /**
25 * Run in the capture phase, ahead of focused widgets that consume the key
26 * themselves (the terminal encodes Ctrl+Shift+T and stops propagation).
27 */
28 readonly capture?: boolean;
29 /**
30 * Hear every bubbling keydown after the scopes ran, with whether one of them
31 * (or the page) consumed it. Observers never consume. For listeners that react to keys other
32 * scopes may also handle, such as scroll keys.
33 */
34 readonly observe?: (event: KeyboardEvent, consumed: boolean) => void;
35}
36
37interface CommandScope {
38 readonly commands: CommandHandlers;
39 readonly options: CommandScopeOptions;
40}
41
42// Within each list the newest scope wins.
43const globalScopes: CommandScope[] = [];
44const captureScopes: CommandScope[] = [];
45const observerScopes: CommandScope[] = [];
46
47function readFocusContext(event: KeyboardEvent): Partial<ShortcutMatchContext> {
48 return {
49 terminalFocus: isTerminalFocused(),
50 previewFocus: isPreviewFocused(),
51 editableFocus: isEditableFocused(event.target),
52 modelPickerOpen: isModelPickerOpen(),
53 };
54}
55
56function isInsideConfirmation(target: EventTarget | null): boolean {
57 return target instanceof Element && target.closest('[role="alertdialog"]') !== null;
58}
59
60function scopeCommandHandler(scope: CommandScope, event: KeyboardEvent) {
61 const { commands, options } = scope;
62 const command = resolveShortcutCommand(
63 event,
64 options.keybindings?.() ?? appAtomRegistry.get(primaryServerKeybindingsAtom),
65 { context: { ...readFocusContext(event), ...options.context?.() } },
66 );
67 return command === null ? undefined : commands[command];
68}
69
70function runScope(scope: CommandScope, event: KeyboardEvent): boolean {
71 const { options } = scope;
72 if (options.when && !options.when(event)) return false;
73 if (options.onKeyDown && options.onKeyDown(event) !== false) return true;
74 const handler = scopeCommandHandler(scope, event);
75 if (handler === undefined) return false;
76 if (isInsideConfirmation(event.target)) {
77 event.preventDefault();
78 return true;
79 }
80 return handler(event) !== false;
81}
82
83/**
84 * Whether a bubble-phase command is bound to this keydown, for widgets that would otherwise
85 * consume it. Capture scopes have already run (and declined) by the time a widget sees it.
86 */
87export function hasCommandForKeyDown(event: KeyboardEvent): boolean {
88 return globalScopes.some(
89 (scope) =>
90 (!scope.options.when || scope.options.when(event)) &&
91 scopeCommandHandler(scope, event) !== undefined,
92 );
93}
94
95function runScopes(scopes: readonly CommandScope[], event: KeyboardEvent): boolean {
96 for (let index = scopes.length - 1; index >= 0; index -= 1) {
97 if (event.defaultPrevented) return true;
98 const scope = scopes[index];
99 if (scope && runScope(scope, event)) return true;
100 }
101 return false;
102}
103
104function dispatchCaptureKeyDown(event: KeyboardEvent): void {
105 runScopes(captureScopes, event);
106}
107
108function dispatchKeyDown(event: KeyboardEvent): void {
109 const consumed = runScopes(globalScopes, event) || event.defaultPrevented;
110 for (const scope of observerScopes.slice()) scope.options.observe?.(event, consumed);
111}
112
113let keydownInstalled = false;
114function ensureKeydownListener() {
115 if (keydownInstalled || typeof window === "undefined") return;
116 keydownInstalled = true;
117 window.addEventListener("keydown", dispatchCaptureKeyDown, true);
118 window.addEventListener("keydown", dispatchKeyDown);
119}
120
121function pushScope(list: CommandScope[], scope: CommandScope): () => void {
122 ensureKeydownListener();
123 list.push(scope);
124 return () => {
125 const index = list.lastIndexOf(scope);
126 if (index !== -1) list.splice(index, 1);
127 };
128}
129
130/** App-global commands, registered once at module level or bootstrap. */
131export function registerCommands(
132 commands: CommandHandlers,
133 options: CommandScopeOptions = {},
134): () => void {
135 const scopes = options.observe ? observerScopes : options.capture ? captureScopes : globalScopes;
136 return pushScope(scopes, { commands, options });
137}
138
139type MenuActionHandler = () => void;
140const menuActionHandlers = new Map<string, MenuActionHandler[]>();
141let menuActionInstalled = false;
142
143export function dispatchMenuAction(action: string): void {
144 const handlers = menuActionHandlers.get(action);
145 handlers?.at(-1)?.();
146}
147
148function ensureMenuActionListener() {
149 if (menuActionInstalled || typeof window === "undefined") return;
150 const bridge = window.desktopBridge;
151 if (typeof bridge?.onMenuAction !== "function") return;
152 menuActionInstalled = true;
153 bridge.onMenuAction(dispatchMenuAction);
154}
155
156/** Desktop menu actions; the newest handler for an action wins. */
157export function registerMenuAction(action: string, handler: MenuActionHandler): () => void {
158 ensureMenuActionListener();
159 const handlers = menuActionHandlers.get(action) ?? [];
160 handlers.push(handler);
161 menuActionHandlers.set(action, handlers);
162 return () => {
163 const index = handlers.lastIndexOf(handler);
164 if (index !== -1) handlers.splice(index, 1);
165 };
166}