import { StackActions, useNavigation } from "@react-navigation/native";import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type PropsWithChildren,} from "react"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";import { useThreadShell } from "../../state/entities";import type { GitActionProgress } from "../../state/use-vcs-action-state";import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";import { dispatchHardwareKeyboardCommand, getHardwareKeyboardCommandRegistrationVersion, getRegisteredHardwareKeyboardCommands, parseActiveThreadPath, subscribeToHardwareKeyboardCommandRegistrations, type HardwareKeyboardCommand,} from "./hardwareKeyboardCommands"; const EMPTY_COPY_FEEDBACK: GitActionProgress = { phase: "idle", label: null, description: null,};const COPY_FEEDBACK_DISMISS_MS = 3_000; export function HardwareKeyboardCommandProvider({ children, pathname,}: PropsWithChildren<{ readonly pathname: string }>) { const navigation = useNavigation(); const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); const activeThread = useThreadShell(activeThreadRef); const copyTarget = useMemo( () => activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ threadId: activeThread?.id ?? activeThreadRef.threadId, pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, }), [activeThread, activeThreadRef], ); const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK); const copyRequestIdRef = useRef(0); const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const dismissCopyFeedback = useCallback(() => { if (copyFeedbackTimerRef.current !== null) { clearTimeout(copyFeedbackTimerRef.current); copyFeedbackTimerRef.current = null; } setCopyFeedback(EMPTY_COPY_FEEDBACK); }, []); const showCopyFeedback = useCallback((feedback: GitActionProgress) => { if (copyFeedbackTimerRef.current !== null) { clearTimeout(copyFeedbackTimerRef.current); } setCopyFeedback(feedback); copyFeedbackTimerRef.current = setTimeout(() => { copyFeedbackTimerRef.current = null; setCopyFeedback(EMPTY_COPY_FEEDBACK); }, COPY_FEEDBACK_DISMISS_MS); }, []); useEffect( () => () => { if (copyFeedbackTimerRef.current !== null) { clearTimeout(copyFeedbackTimerRef.current); } }, [], ); const registrationVersion = useSyncExternalStore( subscribeToHardwareKeyboardCommandRegistrations, getHardwareKeyboardCommandRegistrationVersion, getHardwareKeyboardCommandRegistrationVersion, ); const enabledCommands = useMemo(() => { const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); if (activeThreadRef !== null) { commands.add("files"); commands.add("terminal"); commands.add("review"); if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; }, [pathname, registrationVersion, navigation]); const onCommand = useCallback( (command: HardwareKeyboardCommand) => { if (dispatchHardwareKeyboardCommand(command)) return; if (command === "copyThreadReference") { if (copyTarget === null) return; const requestId = ++copyRequestIdRef.current; void tryCopyTextWithHaptic(copyTarget.value, { target: copyTarget.clipboardTarget, }).then((didCopy) => { if (requestId !== copyRequestIdRef.current) return; showCopyFeedback( didCopy ? { phase: "success", label: copyTarget.successTitle, description: copyTarget.value, } : { phase: "error", label: copyTarget.failureTitle, description: "Try again.", }, ); }); return; } if (command === "newTask") { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); return; } if (command === "back") { if (navigation.canGoBack()) { navigation.goBack(); } else { navigation.dispatch(StackActions.replace("Home")); } return; } const thread = parseActiveThreadPath(pathname); if (!thread) return; if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) { navigation.navigate("ThreadFiles", thread); } if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) { navigation.navigate("ThreadTerminal", thread); } if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) { navigation.navigate("ThreadReview", thread); } }, [copyTarget, navigation, pathname, showCopyFeedback], ); return ( <> <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}> {children} </T3KeyboardCommands> <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} /> </> );}
import { StackActions, useNavigation } from "@react-navigation/native";
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PropsWithChildren,
} from "react";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
import { useThreadShell } from "../../state/entities";
import type { GitActionProgress } from "../../state/use-vcs-action-state";
import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
import {
dispatchHardwareKeyboardCommand,
getHardwareKeyboardCommandRegistrationVersion,
getRegisteredHardwareKeyboardCommands,
parseActiveThreadPath,
subscribeToHardwareKeyboardCommandRegistrations,
type HardwareKeyboardCommand,
} from "./hardwareKeyboardCommands";
const EMPTY_COPY_FEEDBACK: GitActionProgress = {
phase: "idle",
label: null,
description: null,
};
const COPY_FEEDBACK_DISMISS_MS = 3_000;
export function HardwareKeyboardCommandProvider({
children,
pathname,
}: PropsWithChildren<{ readonly pathname: string }>) {
const navigation = useNavigation();
const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
const activeThread = useThreadShell(activeThreadRef);
const copyTarget = useMemo(
() =>
activeThreadRef === null
? null
: resolveThreadReferenceCopyTarget({
threadId: activeThread?.id ?? activeThreadRef.threadId,
pullRequests: activeThread?.pullRequests,
linkedPullRequestUrl:
(activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
}),
[activeThread, activeThreadRef],
);
const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
const copyRequestIdRef = useRef(0);
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissCopyFeedback = useCallback(() => {
if (copyFeedbackTimerRef.current !== null) {
clearTimeout(copyFeedbackTimerRef.current);
copyFeedbackTimerRef.current = null;
}
setCopyFeedback(EMPTY_COPY_FEEDBACK);
}, []);
const showCopyFeedback = useCallback((feedback: GitActionProgress) => {
if (copyFeedbackTimerRef.current !== null) {
clearTimeout(copyFeedbackTimerRef.current);
}
setCopyFeedback(feedback);
copyFeedbackTimerRef.current = setTimeout(() => {
copyFeedbackTimerRef.current = null;
setCopyFeedback(EMPTY_COPY_FEEDBACK);
}, COPY_FEEDBACK_DISMISS_MS);
}, []);
useEffect(
() => () => {
if (copyFeedbackTimerRef.current !== null) {
clearTimeout(copyFeedbackTimerRef.current);
}
},
[],
);
const registrationVersion = useSyncExternalStore(
subscribeToHardwareKeyboardCommandRegistrations,
getHardwareKeyboardCommandRegistrationVersion,
getHardwareKeyboardCommandRegistrationVersion,
);
const enabledCommands = useMemo(() => {
const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
commands.add("newTask");
if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
if (activeThreadRef !== null) {
commands.add("files");
commands.add("terminal");
commands.add("review");
if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
}
return [...commands];
}, [pathname, registrationVersion, navigation]);
const onCommand = useCallback(
(command: HardwareKeyboardCommand) => {
if (dispatchHardwareKeyboardCommand(command)) return;
if (command === "copyThreadReference") {
if (copyTarget === null) return;
const requestId = ++copyRequestIdRef.current;
void tryCopyTextWithHaptic(copyTarget.value, {
target: copyTarget.clipboardTarget,
}).then((didCopy) => {
if (requestId !== copyRequestIdRef.current) return;
showCopyFeedback(
didCopy
? {
phase: "success",
label: copyTarget.successTitle,
description: copyTarget.value,
}
: {
phase: "error",
label: copyTarget.failureTitle,
description: "Try again.",
},
);
});
return;
}
if (command === "newTask") {
navigation.navigate("NewTaskSheet", { screen: "NewTask" });
return;
}
if (command === "back") {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.dispatch(StackActions.replace("Home"));
}
return;
}
const thread = parseActiveThreadPath(pathname);
if (!thread) return;
if (command === "files" && !/\/files(?:\/|$)/.test(pathname)) {
navigation.navigate("ThreadFiles", thread);
}
if (command === "terminal" && !/\/terminal(?:\/|$)/.test(pathname)) {
navigation.navigate("ThreadTerminal", thread);
}
if (command === "review" && !/\/review(?:\/|$)/.test(pathname)) {
navigation.navigate("ThreadReview", thread);
}
},
[copyTarget, navigation, pathname, showCopyFeedback],
);
return (
<>
<T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
{children}
</T3KeyboardCommands>
<GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
</>
);
}