Skip to content
Source
Sign inSign up
Open History to see the last commit

HardwareKeyboardCommandProvider.tsx

204 lines7.3 KB

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