1import { StackActions, useNavigation } from "@react-navigation/native";
2import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
3import {
4 createContext,
5 use,
6 useCallback,
7 useEffect,
8 useMemo,
9 useRef,
10 useState,
11 useSyncExternalStore,
12 type PropsWithChildren,
13 type ReactNode,
14} from "react";
15
16import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
17import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
18import { useThreadShell } from "../../state/entities";
19import type { GitActionProgress } from "../../state/use-vcs-action-state";
20import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
21import { CommandPalette } from "./CommandPalette";
22import {
23 dispatchHardwareKeyboardCommand,
24 getHardwareKeyboardCommandRegistrationVersion,
25 getRegisteredHardwareKeyboardCommands,
26 parseActiveThreadPath,
27 subscribeToHardwareKeyboardCommandRegistrations,
28 type HardwareKeyboardCommand,
29} from "./hardwareKeyboardCommands";
30
31const EMPTY_COPY_FEEDBACK: GitActionProgress = {
32 phase: "idle",
33 label: null,
34 description: null,
35};
36const COPY_FEEDBACK_DISMISS_MS = 3_000;
37
38const CommandPaletteContext = createContext<ReactNode>(null);
39
40/** Render inside the workspace so palette actions share its navigation and pane state. */
41export function HardwareKeyboardCommandOverlay() {
42 return use(CommandPaletteContext);
43}
44
45export function HardwareKeyboardCommandProvider({
46 children,
47 pathname,
48}: PropsWithChildren<{ readonly pathname: string }>) {
49 const navigation = useNavigation();
50 const [paletteOpen, setPaletteOpen] = useState(false);
51 const closePalette = useCallback(() => setPaletteOpen(false), []);
52 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
53 const activeThread = useThreadShell(activeThreadRef);
54 const copyTarget = useMemo(
55 () =>
56 activeThreadRef === null
57 ? null
58 : resolveThreadReferenceCopyTarget({
59 threadId: activeThread?.id ?? activeThreadRef.threadId,
60 pullRequests: activeThread?.pullRequests,
61 linkedPullRequestUrl:
62 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
63 }),
64 [activeThread, activeThreadRef],
65 );
66 const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
67 const copyRequestIdRef = useRef(0);
68 const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69 const dismissCopyFeedback = useCallback(() => {
70 if (copyFeedbackTimerRef.current !== null) {
71 clearTimeout(copyFeedbackTimerRef.current);
72 copyFeedbackTimerRef.current = null;
73 }
74 setCopyFeedback(EMPTY_COPY_FEEDBACK);
75 }, []);
76 const showCopyFeedback = useCallback((feedback: GitActionProgress) => {
77 if (copyFeedbackTimerRef.current !== null) {
78 clearTimeout(copyFeedbackTimerRef.current);
79 }
80 setCopyFeedback(feedback);
81 copyFeedbackTimerRef.current = setTimeout(() => {
82 copyFeedbackTimerRef.current = null;
83 setCopyFeedback(EMPTY_COPY_FEEDBACK);
84 }, COPY_FEEDBACK_DISMISS_MS);
85 }, []);
86 useEffect(
87 () => () => {
88 if (copyFeedbackTimerRef.current !== null) {
89 clearTimeout(copyFeedbackTimerRef.current);
90 }
91 },
92 [],
93 );
94 const registrationVersion = useSyncExternalStore(
95 subscribeToHardwareKeyboardCommandRegistrations,
96 getHardwareKeyboardCommandRegistrationVersion,
97 getHardwareKeyboardCommandRegistrationVersion,
98 );
99 const enabledCommands = useMemo(() => {
100 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
101 commands.add("newTask");
102 commands.add("commandPalette");
103 if (pathname !== "/" && !pathname.startsWith("/threads/")) {
104 for (const command of commands) {
105 if (command.startsWith("thread.jump.")) commands.delete(command);
106 }
107 }
108 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
109 if (activeThreadRef !== null) {
110 commands.add("files");
111 commands.add("terminal");
112 commands.add("review");
113 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
114 }
115 return [...commands];
116 }, [activeThreadRef, pathname, registrationVersion, navigation]);
117
118 const onCommand = useCallback(
119 (command: HardwareKeyboardCommand) => {
120 if (command === "commandPalette") {
121 setPaletteOpen(true);
122 return;
123 }
124 if (dispatchHardwareKeyboardCommand(command)) return;
125
126 if (command === "copyThreadReference") {
127 if (copyTarget === null) return;
128 const requestId = ++copyRequestIdRef.current;
129 void tryCopyTextWithHaptic(copyTarget.value, {
130 target: copyTarget.clipboardTarget,
131 }).then((didCopy) => {
132 if (requestId !== copyRequestIdRef.current) return;
133 showCopyFeedback(
134 didCopy
135 ? {
136 phase: "success",
137 label: copyTarget.successTitle,
138 description: copyTarget.value,
139 }
140 : {
141 phase: "error",
142 label: copyTarget.failureTitle,
143 description: "Try again.",
144 },
145 );
146 });
147 return;
148 }
149
150 if (command === "newTask") {
151 navigation.navigate("NewTaskSheet", { screen: "NewTask" });
152 return;
153 }
154 if (command === "back") {
155 if (navigation.canGoBack()) {
156 navigation.goBack();
157 } else {
158 navigation.dispatch(StackActions.replace("Home"));
159 }
160 return;
161 }
162
163 const thread = parseActiveThreadPath(pathname);
164 if (!thread) return;
165 if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) {
166 navigation.navigate("ThreadFiles", thread);
167 }
168 if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) {
169 navigation.navigate("ThreadTerminal", thread);
170 }
171 if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) {
172 navigation.navigate("ThreadReview", thread);
173 }
174 },
175 [copyTarget, navigation, pathname, showCopyFeedback],
176 );
177
178 const palette = useMemo(
179 () =>
180 paletteOpen ? (
181 <CommandPalette pathname={pathname} onClose={closePalette} onCommand={onCommand} />
182 ) : null,
183 [closePalette, onCommand, paletteOpen, pathname],
184 );
185
186 return (
187 <CommandPaletteContext value={palette}>
188 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
189 {children}
190 </T3KeyboardCommands>
191 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
192 </CommandPaletteContext>
193 );
194}