1import { EnvironmentId, ThreadId, type ThreadJumpKeybindingCommand } from "@t3tools/contracts";
2import { useEffect } from "react";
3
4export type HardwareKeyboardCommand =
5 | ThreadJumpKeybindingCommand
6 | "commandPalette"
7 | "paletteNext"
8 | "palettePrevious"
9 | "paletteDismiss"
10 | "newTask"
11 | "focusSearch"
12 | "back"
13 | "files"
14 | "terminal"
15 | "review"
16 | "copyThreadReference"
17 | "toggleSidebar"
18 | "cycleHost";
19
20type CommandHandler = (command: HardwareKeyboardCommand) => boolean | void;
21
22const handlers = new Map<HardwareKeyboardCommand, Set<CommandHandler>>();
23const registrationListeners = new Set<() => void>();
24let registrationVersion = 0;
25
26export function useHardwareKeyboardCommand(
27 command: HardwareKeyboardCommand | ReadonlyArray<HardwareKeyboardCommand>,
28 handler: CommandHandler,
29): void {
30 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the handler in the module-level hardware keyboard command registry
31 useEffect(() => {
32 const commands = typeof command === "string" ? [command] : command;
33 for (const command of commands) {
34 const commandHandlers = handlers.get(command) ?? new Set<CommandHandler>();
35 commandHandlers.add(handler);
36 handlers.set(command, commandHandlers);
37 }
38 registrationVersion += 1;
39 registrationListeners.forEach((listener) => listener());
40 return () => {
41 for (const command of commands) {
42 const commandHandlers = handlers.get(command);
43 commandHandlers?.delete(handler);
44 if (commandHandlers?.size === 0) handlers.delete(command);
45 }
46 registrationVersion += 1;
47 registrationListeners.forEach((listener) => listener());
48 };
49 }, [command, handler]);
50}
51
52export function getRegisteredHardwareKeyboardCommands(): ReadonlySet<HardwareKeyboardCommand> {
53 return new Set(handlers.keys());
54}
55
56export function getHardwareKeyboardCommandRegistrationVersion(): number {
57 return registrationVersion;
58}
59
60export function subscribeToHardwareKeyboardCommandRegistrations(listener: () => void): () => void {
61 registrationListeners.add(listener);
62 return () => registrationListeners.delete(listener);
63}
64
65export function dispatchHardwareKeyboardCommand(command: HardwareKeyboardCommand): boolean {
66 const commandHandlers = handlers.get(command);
67 if (!commandHandlers) return false;
68 const handlersInRegistrationOrder = Array.from(commandHandlers);
69 for (let index = handlersInRegistrationOrder.length - 1; index >= 0; index -= 1) {
70 const handler = handlersInRegistrationOrder[index];
71 if (!handler) continue;
72 if (handler(command) !== false) return true;
73 }
74 return false;
75}
76
77export function parseActiveThreadPath(pathname: string): {
78 readonly environmentId: EnvironmentId;
79 readonly threadId: ThreadId;
80} | null {
81 const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
82 if (!match?.[1] || !match[2]) return null;
83 try {
84 return {
85 environmentId: EnvironmentId.make(decodeURIComponent(match[1])),
86 threadId: ThreadId.make(decodeURIComponent(match[2])),
87 };
88 } catch {
89 return null;
90 }
91}
92
93export function nextEnvironmentId<T extends { readonly environmentId: string }>(
94 environments: ReadonlyArray<T>,
95 currentId: string | null,
96): T["environmentId"] | null {
97 if (environments.length < 2) return null;
98 const index = environments.findIndex((environment) => environment.environmentId === currentId);
99 return environments[(index + 1) % environments.length]?.environmentId ?? null;
100}