| 1 | import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 3 | import { Alert, View } from "react-native"; |
| 4 | import { |
| 5 | isAtomCommandInterrupted, |
| 6 | squashAtomCommandFailure, |
| 7 | } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { useProjects } from "../../state/entities"; |
| 10 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 11 | import { useWorkspaceState } from "../../state/workspace"; |
| 12 | import { vcsEnvironment } from "../../state/vcs"; |
| 13 | import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; |
| 14 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 15 | |
| 16 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; |
| 17 | |
| 18 | type NewTaskDraftRouteParams = { |
| 19 | readonly environmentId?: string | string[]; |
| 20 | readonly projectId?: string | string[]; |
| 21 | readonly branch?: string | null; |
| 22 | readonly worktreePath?: string | null; |
| 23 | readonly title?: string | string[]; |
| 24 | /** Set by Add Project when this draft opens while the project's clone runs. */ |
| 25 | readonly cloning?: string | string[]; |
| 26 | readonly pendingTaskId?: string | string[]; |
| 27 | readonly draftId?: string | string[]; |
| 28 | readonly incomingShareId?: string | string[]; |
| 29 | }; |
| 30 | |
| 31 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { |
| 32 | const params = useMemo(() => route.params ?? {}, [route.params]); |
| 33 | const pendingTaskId = Array.isArray(params.pendingTaskId) |
| 34 | ? params.pendingTaskId[0] |
| 35 | : params.pendingTaskId; |
| 36 | const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; |
| 37 | const projects = useProjects(); |
| 38 | const { state: catalogState } = useWorkspaceState(); |
| 39 | const navigation = useNavigation(); |
| 40 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 41 | |
| 42 | // Keyed on the params object so a fresh navigation to this (already |
| 43 | // mounted) screen produces a new reference, letting the draft screen |
| 44 | // re-apply the requested project. |
| 45 | const initialProjectRef = useMemo( |
| 46 | () => ({ |
| 47 | environmentId: Array.isArray(params.environmentId) |
| 48 | ? params.environmentId[0] |
| 49 | : params.environmentId, |
| 50 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, |
| 51 | branch: params.branch, |
| 52 | worktreePath: params.worktreePath, |
| 53 | cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1", |
| 54 | }), |
| 55 | [params], |
| 56 | ); |
| 57 | |
| 58 | const [preparation, setPreparation] = useState<{ |
| 59 | request: typeof initialProjectRef; |
| 60 | result: Awaited<ReturnType<typeof checkoutNewTaskBranch>>; |
| 61 | workspaceRoot: string | undefined; |
| 62 | } | null>(null); |
| 63 | const project = projects.find( |
| 64 | (candidate) => |
| 65 | candidate.environmentId === initialProjectRef.environmentId && |
| 66 | candidate.id === initialProjectRef.projectId, |
| 67 | ); |
| 68 | const environmentId = project?.environmentId; |
| 69 | const workspaceRoot = project?.workspaceRoot; |
| 70 | const needsPreparation = Boolean(initialProjectRef.branch && !pendingTaskId && !draftId); |
| 71 | |
| 72 | const [pendingCheckouts, setPendingCheckouts] = useState(0); |
| 73 | const checkoutTail = useRef(Promise.resolve()); |
| 74 | const waitingForProject = |
| 75 | !project && |
| 76 | (catalogState.isLoadingConnections || |
| 77 | (!catalogState.hasLoadedShellSnapshot && |
| 78 | catalogState.hasConnectingEnvironment && |
| 79 | catalogState.connectionError === null)); |
| 80 | |
| 81 | useEffect(() => { |
| 82 | if (!needsPreparation || !initialProjectRef.branch || waitingForProject) return; |
| 83 | const branchName = initialProjectRef.branch; |
| 84 | let active = true; |
| 85 | setPendingCheckouts((count) => count + 1); |
| 86 | // Serialize replacements: ignoring a stale result cannot undo its Git mutation. |
| 87 | checkoutTail.current = checkoutTail.current.then(async () => { |
| 88 | if (!active) { |
| 89 | setPendingCheckouts((count) => count - 1); |
| 90 | return; |
| 91 | } |
| 92 | const result = await checkoutNewTaskBranch({ |
| 93 | // A thread's branch is historical; only switchRef can establish that |
| 94 | // the shared project checkout now matches it. |
| 95 | branch: { |
| 96 | name: branchName, |
| 97 | current: false, |
| 98 | isDefault: false, |
| 99 | worktreePath: initialProjectRef.worktreePath ?? null, |
| 100 | }, |
| 101 | project: environmentId && workspaceRoot ? { environmentId, workspaceRoot } : null, |
| 102 | workspaceMode: "local", |
| 103 | switchRef, |
| 104 | }); |
| 105 | setPendingCheckouts((count) => count - 1); |
| 106 | if (active) setPreparation({ request: initialProjectRef, result, workspaceRoot }); |
| 107 | }); |
| 108 | return () => { |
| 109 | active = false; |
| 110 | }; |
| 111 | }, [ |
| 112 | environmentId, |
| 113 | workspaceRoot, |
| 114 | initialProjectRef, |
| 115 | needsPreparation, |
| 116 | switchRef, |
| 117 | waitingForProject, |
| 118 | ]); |
| 119 | |
| 120 | const result = |
| 121 | preparation?.request === initialProjectRef && preparation.workspaceRoot === workspaceRoot |
| 122 | ? preparation.result |
| 123 | : null; |
| 124 | // The native-stack guard covers iOS swipe dismissal as well as back actions. |
| 125 | // A replaced request must settle too before the shared checkout is left behind. |
| 126 | const checkoutPending = pendingCheckouts > 0 || (needsPreparation && result === null); |
| 127 | usePreventRemove(checkoutPending, () => undefined); |
| 128 | useEffect(() => { |
| 129 | if (checkoutPending || result?._tag !== "Failure") return; |
| 130 | if (!isAtomCommandInterrupted(result)) { |
| 131 | const error = squashAtomCommandFailure(result); |
| 132 | Alert.alert( |
| 133 | "Could not switch branch", |
| 134 | error instanceof Error ? error.message : "The branch could not be checked out.", |
| 135 | ); |
| 136 | } |
| 137 | navigation.goBack(); |
| 138 | }, [checkoutPending, result, navigation]); |
| 139 | |
| 140 | const preparedProjectRef = useMemo( |
| 141 | () => |
| 142 | result?._tag === "Success" |
| 143 | ? { ...initialProjectRef, branch: result.value.name } |
| 144 | : initialProjectRef, |
| 145 | [initialProjectRef, result], |
| 146 | ); |
| 147 | // Send/queue remain unavailable on failure while the unlocked route closes. |
| 148 | const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); |
| 149 | |
| 150 | return ( |
| 151 | <> |
| 152 | <NativeStackScreenOptions |
| 153 | options={{ |
| 154 | title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"), |
| 155 | }} |
| 156 | /> |
| 157 | {preparingBranch ? ( |
| 158 | <View className="flex-1 items-center justify-center bg-screen"> |
| 159 | <Text className="text-foreground">Switching branch...</Text> |
| 160 | </View> |
| 161 | ) : ( |
| 162 | <NewTaskDraftScreen |
| 163 | initialProjectRef={preparedProjectRef} |
| 164 | incomingShareId={ |
| 165 | Array.isArray(params.incomingShareId) |
| 166 | ? params.incomingShareId[0] |
| 167 | : params.incomingShareId |
| 168 | } |
| 169 | pendingTaskId={pendingTaskId} |
| 170 | draftId={draftId} |
| 171 | /> |
| 172 | )} |
| 173 | </> |
| 174 | ); |
| 175 | } |