| author | |
| committer | |
| log | 549d182aaadbf0e1e195d1a8cdc1805200e6751e |
| tree | c282279557c316036724112d3441afeba2f38fe9 |
| parent | 8d7c700c1788c4f42932cd3be541b3c630bd664d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 340 insertions(+), 10 deletions(-)
apps/mobile/src/components/ProjectCloneBanner.tsx created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import { | |
| 2 | projectCloneDisplayName, | |
| 3 | projectCloneProgressSummary, | |
| 4 | type ProjectCloneSnapshot, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { ActivityIndicator, Pressable, View } from "react-native"; | |
| 7 | ||
| 8 | import { cn } from "../lib/cn"; | |
| 9 | import { AppText as Text } from "./AppText"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Live state of the clone that backs a freshly added project, shown above | |
| 13 | * the composer while the draft waits for its files. Running clones offer | |
| 14 | * Cancel; failed or cancelled ones offer Retry and Remove project. | |
| 15 | */ | |
| 16 | export function ProjectCloneBanner(props: { | |
| 17 | readonly clone: ProjectCloneSnapshot; | |
| 18 | readonly onCancel: () => void; | |
| 19 | readonly onRetry: () => void; | |
| 20 | readonly onRemove: () => void; | |
| 21 | }) { | |
| 22 | const { clone } = props; | |
| 23 | const name = projectCloneDisplayName(clone); | |
| 24 | if (clone.phase === "running") { | |
| 25 | return ( | |
| 26 | <View className="flex-row items-center gap-3 rounded-2xl border border-border bg-card px-3.5 py-3"> | |
| 27 | <ActivityIndicator size="small" /> | |
| 28 | <View className="min-w-0 flex-1"> | |
| 29 | <Text className="font-t3-medium text-sm" numberOfLines={1}> | |
| 30 | Cloning {name} | |
| 31 | </Text> | |
| 32 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 33 | {projectCloneProgressSummary(clone)} | |
| 34 | </Text> | |
| 35 | </View> | |
| 36 | <BannerAction label="Cancel" onPress={props.onCancel} /> | |
| 37 | </View> | |
| 38 | ); | |
| 39 | } | |
| 40 | const cancelled = clone.phase === "cancelled"; | |
| 41 | return ( | |
| 42 | <View | |
| 43 | className={cn( | |
| 44 | "rounded-2xl border px-3.5 py-3", | |
| 45 | cancelled ? "border-warning-border bg-warning" : "border-danger-border bg-danger", | |
| 46 | )} | |
| 47 | > | |
| 48 | <Text | |
| 49 | className={cn( | |
| 50 | "font-t3-medium text-sm", | |
| 51 | cancelled ? "text-warning-foreground" : "text-danger-foreground", | |
| 52 | )} | |
| 53 | numberOfLines={1} | |
| 54 | > | |
| 55 | {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`} | |
| 56 | </Text> | |
| 57 | {clone.error ? ( | |
| 58 | <Text className="mt-0.5 text-xs text-danger-foreground" numberOfLines={3}> | |
| 59 | {clone.error} | |
| 60 | </Text> | |
| 61 | ) : null} | |
| 62 | <View className="mt-2 flex-row justify-end gap-2"> | |
| 63 | <BannerAction label="Remove project" onPress={props.onRemove} /> | |
| 64 | <BannerAction label="Retry" onPress={props.onRetry} /> | |
| 65 | </View> | |
| 66 | </View> | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | function BannerAction(props: { readonly label: string; readonly onPress: () => void }) { | |
| 71 | return ( | |
| 72 | <Pressable | |
| 73 | accessibilityRole="button" | |
| 74 | accessibilityLabel={props.label} | |
| 75 | className="rounded-full border border-border bg-background px-3 py-1.5" | |
| 76 | onPress={props.onPress} | |
| 77 | > | |
| 78 | <Text className="font-t3-medium text-xs">{props.label}</Text> | |
| 79 | </Pressable> | |
| 80 | ); | |
| 81 | } |
apps/mobile/src/features/projects/AddProjectScreen.tsx+61-1| ... | ... | @@ -49,7 +49,7 @@ import * as Order from "effect/Order"; |
| 49 | 49 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 50 | 50 | import { cn } from "../../lib/cn"; |
| 51 | 51 | |
| 52 | import { useProjects, useServerConfigs } from "../../state/entities"; | |
| 52 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; | |
| 53 | 53 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 54 | 54 | import { projectEnvironment } from "../../state/projects"; |
| 55 | 55 | import { useEnvironmentQuery } from "../../state/query"; |
| ... | ... | @@ -77,6 +77,8 @@ interface EnvironmentOption { |
| 77 | 77 | readonly connectionState: EnvironmentConnectionPhase; |
| 78 | 78 | readonly connectionError: string | null; |
| 79 | 79 | readonly connectionErrorTraceId: string | null; |
| 80 | /** Server runs clones in the background and streams progress; older servers block. */ | |
| 81 | readonly supportsCloneTracking: boolean; | |
| 80 | 82 | } |
| 81 | 83 | |
| 82 | 84 | const environmentOptionOrder = Order.mapInput( |
| ... | ... | @@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { |
| 366 | 368 | connectionState: runtime?.connectionState ?? "available", |
| 367 | 369 | connectionError: runtime?.connectionError ?? null, |
| 368 | 370 | connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, |
| 371 | supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true, | |
| 369 | 372 | }; |
| 370 | 373 | }); |
| 371 | 374 | return Arr.sort(options, environmentOptionOrder); |
| ... | ... | @@ -575,6 +578,15 @@ export function AddProjectSourceScreen() { |
| 575 | 578 | ); |
| 576 | 579 | } |
| 577 | 580 | |
| 581 | function openNewTaskDraft( | |
| 582 | navigation: { dispatch: (action: ReturnType<typeof CommonActions.reset>) => void }, | |
| 583 | params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" }, | |
| 584 | ) { | |
| 585 | navigation.dispatch( | |
| 586 | CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), | |
| 587 | ); | |
| 588 | } | |
| 589 | ||
| 578 | 590 | function useCreateProject(environment: EnvironmentOption | null) { |
| 579 | 591 | const navigation = useNavigation(); |
| 580 | 592 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| ... | ... | @@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: { |
| 908 | 920 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { |
| 909 | 921 | reportFailure: false, |
| 910 | 922 | }); |
| 923 | const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { | |
| 924 | reportFailure: false, | |
| 925 | }); | |
| 926 | const navigation = useNavigation(); | |
| 911 | 927 | const environment = useEnvironmentFromParam(props.environmentId); |
| 912 | 928 | const createProject = useCreateProject(environment); |
| 913 | 929 | const remoteUrl = stringParam(props.remoteUrl); |
| ... | ... | @@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: { |
| 938 | 954 | } |
| 939 | 955 | |
| 940 | 956 | setIsSubmitting(true); |
| 957 | if (environment.supportsCloneTracking) { | |
| 958 | // The server creates the project and clones in the background; the | |
| 959 | // draft screen shows progress and holds Start until the files land. | |
| 960 | const projectId = ProjectId.make(uuidv4()); | |
| 961 | const title = inferProjectTitleFromPath(resolved.path); | |
| 962 | const startResult = await startProjectClone({ | |
| 963 | environmentId: environment.environmentId, | |
| 964 | input: { | |
| 965 | projectId, | |
| 966 | title, | |
| 967 | createdAt: new Date().toISOString(), | |
| 968 | remoteUrl, | |
| 969 | destinationPath: resolved.path, | |
| 970 | }, | |
| 971 | }); | |
| 972 | if (AsyncResult.isFailure(startResult)) { | |
| 973 | setError(errorMessage(Cause.squash(startResult.cause))); | |
| 974 | } else { | |
| 975 | // The draft screen resolves its project from the client store, so it | |
| 976 | // must not open before the create event has arrived (it would fall | |
| 977 | // back to the project picker and lose the clone controls). Stay in | |
| 978 | // the submitting state until then; the clone keeps running either way. | |
| 979 | const project = await waitForProject( | |
| 980 | { environmentId: environment.environmentId, projectId }, | |
| 981 | 15_000, | |
| 982 | ); | |
| 983 | if (project === null) { | |
| 984 | setError( | |
| 985 | "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", | |
| 986 | ); | |
| 987 | } else { | |
| 988 | openNewTaskDraft(navigation, { | |
| 989 | environmentId: environment.environmentId, | |
| 990 | projectId, | |
| 991 | title, | |
| 992 | cloning: "1", | |
| 993 | }); | |
| 994 | } | |
| 995 | } | |
| 996 | setIsSubmitting(false); | |
| 997 | return; | |
| 998 | } | |
| 941 | 999 | const cloneResult = await cloneRepository({ |
| 942 | 1000 | environmentId: environment.environmentId, |
| 943 | 1001 | input: { |
| ... | ... | @@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: { |
| 960 | 1018 | environment, |
| 961 | 1019 | isBrowseNavigating, |
| 962 | 1020 | isSubmitting, |
| 1021 | navigation, | |
| 963 | 1022 | pathInput, |
| 964 | 1023 | remoteUrl, |
| 1024 | startProjectClone, | |
| 965 | 1025 | ]); |
| 966 | 1026 | |
| 967 | 1027 | return ( |
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+3| ... | ... | @@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = { |
| 21 | 21 | readonly branch?: string | null; |
| 22 | 22 | readonly worktreePath?: string | null; |
| 23 | 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[]; | |
| 24 | 26 | readonly pendingTaskId?: string | string[]; |
| 25 | 27 | readonly draftId?: string | string[]; |
| 26 | 28 | readonly incomingShareId?: string | string[]; |
| ... | ... | @@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf |
| 48 | 50 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, |
| 49 | 51 | branch: params.branch, |
| 50 | 52 | worktreePath: params.worktreePath, |
| 53 | cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1", | |
| 51 | 54 | }), |
| 52 | 55 | [params], |
| 53 | 56 | ); |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+113-9| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 2 | 4 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; |
| 3 | 5 | import { |
| 4 | 6 | nextPastedTextFileName, |
| ... | ... | @@ -103,6 +105,11 @@ import { |
| 103 | 105 | waitForComposerDraftsLoaded, |
| 104 | 106 | } from "../../state/use-composer-drafts"; |
| 105 | 107 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 108 | import { useProjectClone } from "../../state/projectClones"; | |
| 109 | import { projectEnvironment } from "../../state/projects"; | |
| 110 | import { sourceControlEnvironment } from "../../state/sourceControl"; | |
| 111 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 112 | import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; | |
| 106 | 113 | import { |
| 107 | 114 | isModelSelectionUnavailable, |
| 108 | 115 | resolveSelectableModelSelection, |
| ... | ... | @@ -165,6 +172,8 @@ export function NewTaskDraftScreen(props: { |
| 165 | 172 | readonly projectId?: string; |
| 166 | 173 | readonly branch?: string | null; |
| 167 | 174 | readonly worktreePath?: string | null; |
| 175 | /** The project was just added by a clone that is still running. */ | |
| 176 | readonly cloning?: boolean; | |
| 168 | 177 | }; |
| 169 | 178 | /** Queued outbox message id when editing an existing pending task. */ |
| 170 | 179 | readonly pendingTaskId?: string; |
| ... | ... | @@ -198,6 +207,70 @@ export function NewTaskDraftScreen(props: { |
| 198 | 207 | (environment) => environment.environmentId === selectedProject.environmentId, |
| 199 | 208 | )?.connectionState === "connected"; |
| 200 | 209 | const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; |
| 210 | // A project added by cloning exists before its files do: the prompt can be | |
| 211 | // written meanwhile, but Start waits for the clone. | |
| 212 | const projectCloneState = useProjectClone( | |
| 213 | selectedProject | |
| 214 | ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id } | |
| 215 | : null, | |
| 216 | ); | |
| 217 | const projectClone = projectCloneState === "pending" ? null : projectCloneState; | |
| 218 | // Before the clone stream delivers, only the project this draft was opened | |
| 219 | // for by Add Project is known to be cloning; any other project (offline | |
| 220 | // included) must still be able to queue a task. | |
| 221 | const awaitingKnownClone = | |
| 222 | projectCloneState === "pending" && | |
| 223 | props.initialProjectRef?.cloning === true && | |
| 224 | selectedProject !== null && | |
| 225 | selectedProject.id === props.initialProjectRef.projectId && | |
| 226 | selectedProject.environmentId === props.initialProjectRef.environmentId; | |
| 227 | const cloneBlocksStart = | |
| 228 | awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done"); | |
| 229 | const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { | |
| 230 | reportFailure: false, | |
| 231 | }); | |
| 232 | const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { | |
| 233 | reportFailure: false, | |
| 234 | }); | |
| 235 | // The banner only reflects the server's state, so a request that never got | |
| 236 | // there needs its own feedback. | |
| 237 | const runCloneAction = async ( | |
| 238 | title: string, | |
| 239 | action: () => Promise<AsyncResult.AsyncResult<unknown, unknown>>, | |
| 240 | ) => { | |
| 241 | const result = await action(); | |
| 242 | if (AsyncResult.isFailure(result)) { | |
| 243 | const error = Cause.squash(result.cause); | |
| 244 | Alert.alert(title, error instanceof Error ? error.message : "An error occurred."); | |
| 245 | } | |
| 246 | }; | |
| 247 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | |
| 248 | // The delete is awaited; by then the picker may point somewhere else, and | |
| 249 | // only the removed project's draft should leave the screen. | |
| 250 | const selectedProjectRef = useRef(selectedProject); | |
| 251 | useEffect(() => { | |
| 252 | selectedProjectRef.current = selectedProject; | |
| 253 | }, [selectedProject]); | |
| 254 | const removeClonedProject = async () => { | |
| 255 | if (!selectedProject) return; | |
| 256 | const removed = selectedProject; | |
| 257 | const result = await deleteProject({ | |
| 258 | environmentId: removed.environmentId, | |
| 259 | input: { projectId: removed.id }, | |
| 260 | }); | |
| 261 | if (AsyncResult.isFailure(result)) { | |
| 262 | const error = Cause.squash(result.cause); | |
| 263 | Alert.alert( | |
| 264 | "Failed to remove project", | |
| 265 | error instanceof Error ? error.message : "An error occurred.", | |
| 266 | ); | |
| 267 | return; | |
| 268 | } | |
| 269 | const current = selectedProjectRef.current; | |
| 270 | if (current?.id === removed.id && current.environmentId === removed.environmentId) { | |
| 271 | navigation.dispatch(StackActions.replace("Home")); | |
| 272 | } | |
| 273 | }; | |
| 201 | 274 | const uploadStates = useAtomValue(composerAttachmentUploadsAtom); |
| 202 | 275 | const attachmentBlockReason = selectedProject |
| 203 | 276 | ? composerAttachmentUploadBlockReason({ |
| ... | ... | @@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: { |
| 1261 | 1334 | const isAndroid = Platform.OS === "android"; |
| 1262 | 1335 | const canStart = |
| 1263 | 1336 | !isImportingContext && |
| 1337 | !cloneBlocksStart && | |
| 1264 | 1338 | attachmentBlockReason === null && |
| 1265 | 1339 | !modelUnavailable && |
| 1266 | 1340 | Boolean(flow.selectedProject) && |
| ... | ... | @@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: { |
| 1477 | 1551 | /> |
| 1478 | 1552 | </View> |
| 1479 | 1553 | ) : null} |
| 1554 | {/* Above the workspace controls so they keep their place relative to | |
| 1555 | the composer when the banner goes away once the clone lands. */} | |
| 1556 | {projectClone && projectClone.phase !== "done" && selectedProject ? ( | |
| 1557 | <View className="px-1 pb-2"> | |
| 1558 | <ProjectCloneBanner | |
| 1559 | clone={projectClone} | |
| 1560 | onCancel={() => | |
| 1561 | void runCloneAction("Failed to cancel clone", () => | |
| 1562 | cancelProjectClone({ | |
| 1563 | environmentId: selectedProject.environmentId, | |
| 1564 | input: { projectId: selectedProject.id }, | |
| 1565 | }), | |
| 1566 | ) | |
| 1567 | } | |
| 1568 | onRetry={() => | |
| 1569 | void runCloneAction("Failed to retry clone", () => | |
| 1570 | retryProjectClone({ | |
| 1571 | environmentId: selectedProject.environmentId, | |
| 1572 | input: { projectId: selectedProject.id }, | |
| 1573 | }), | |
| 1574 | ) | |
| 1575 | } | |
| 1576 | onRemove={() => void removeClonedProject()} | |
| 1577 | /> | |
| 1578 | </View> | |
| 1579 | ) : null} | |
| 1480 | 1580 | <View className="pb-1">{workspaceControls}</View> |
| 1481 | 1581 | |
| 1482 | 1582 | {modelUnavailable ? ( |
| ... | ... | @@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: { |
| 1618 | 1718 | <ComposerActionButton |
| 1619 | 1719 | accessibilityLabel={ |
| 1620 | 1720 | attachmentBlockReason ?? |
| 1621 | (pendingPastedTextAttachmentCount > 0 | |
| 1622 | ? "Attaching pasted text" | |
| 1623 | : flow.submitting | |
| 1624 | ? "Starting task" | |
| 1625 | : attachmentsUploading | |
| 1626 | ? "Queue task, sends when uploads finish" | |
| 1627 | : environmentConnected | |
| 1628 | ? "Start task" | |
| 1629 | : "Queue task") | |
| 1721 | (cloneBlocksStart | |
| 1722 | ? projectClone === null || projectClone.phase === "running" | |
| 1723 | ? "Cloning repository" | |
| 1724 | : "Repository not cloned" | |
| 1725 | : pendingPastedTextAttachmentCount > 0 | |
| 1726 | ? "Attaching pasted text" | |
| 1727 | : flow.submitting | |
| 1728 | ? "Starting task" | |
| 1729 | : attachmentsUploading | |
| 1730 | ? "Queue task, sends when uploads finish" | |
| 1731 | : environmentConnected | |
| 1732 | ? "Start task" | |
| 1733 | : "Queue task") | |
| 1630 | 1734 | } |
| 1631 | 1735 | disabled={!canStart} |
| 1632 | 1736 | icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} |
apps/mobile/src/state/entities.ts+27| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | ||
| 3 | import { appAtomRegistry } from "./atom-registry"; | |
| 2 | 4 | import type { |
| 3 | 5 | EnvironmentProject, |
| 4 | 6 | EnvironmentThreadShell, |
| ... | ... | @@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe( |
| 25 | 27 | Atom.withLabel("mobile-server-config:empty"), |
| 26 | 28 | ); |
| 27 | 29 | |
| 30 | /** Resolves when the project event reaches the live client store. */ | |
| 31 | export function waitForProject( | |
| 32 | ref: ScopedProjectRef, | |
| 33 | timeoutMs = 10_000, | |
| 34 | ): Promise<EnvironmentProject | null> { | |
| 35 | const atom = environmentProjects.projectAtom(ref); | |
| 36 | const current = appAtomRegistry.get(atom); | |
| 37 | if (current !== null) return Promise.resolve(current); | |
| 38 | return new Promise((resolve) => { | |
| 39 | let unsubscribe: (() => void) | null = null; | |
| 40 | const timeout = setTimeout(() => { | |
| 41 | unsubscribe?.(); | |
| 42 | resolve(null); | |
| 43 | }, timeoutMs); | |
| 44 | const finish = (project: EnvironmentProject | null) => { | |
| 45 | if (project === null) return; | |
| 46 | clearTimeout(timeout); | |
| 47 | unsubscribe?.(); | |
| 48 | resolve(project); | |
| 49 | }; | |
| 50 | unsubscribe = appAtomRegistry.subscribe(atom, finish); | |
| 51 | finish(appAtomRegistry.get(atom)); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 28 | 55 | export function useProjects(): ReadonlyArray<EnvironmentProject> { |
| 29 | 56 | return useAtomValue(environmentProjects.projectsAtom); |
| 30 | 57 | } |
apps/mobile/src/state/projectClones.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; | |
| 3 | import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 6 | ||
| 7 | import { serverEnvironment } from "./server"; | |
| 8 | import { sourceControlEnvironment } from "./sourceControl"; | |
| 9 | ||
| 10 | /** | |
| 11 | * `"pending"` while the environment's clone stream has not delivered its | |
| 12 | * first list yet: the draft opens right after a clone starts, and a caller | |
| 13 | * that treated the gap as "no clone" would enable Start for a moment. | |
| 14 | */ | |
| 15 | export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null; | |
| 16 | ||
| 17 | const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = []; | |
| 18 | const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneState>(null).pipe( | |
| 19 | Atom.withLabel("mobile-project-clone:empty"), | |
| 20 | ); | |
| 21 | ||
| 22 | /** | |
| 23 | * Latest clone list an environment has streamed, `"pending"` until the first | |
| 24 | * one, and never subscribed on servers that predate clone tracking. | |
| 25 | */ | |
| 26 | const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => | |
| 27 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> | "pending" => { | |
| 28 | const config = get(serverEnvironment.configValueAtom(environmentId)); | |
| 29 | if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES; | |
| 30 | const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); | |
| 31 | // A failed subscription must not hold Start forever. Treating it as | |
| 32 | // "no clone tracked" lets the server's own dispatch guard decide; the | |
| 33 | // registry re-establishes the stream on reconnect. | |
| 34 | if (result._tag === "Failure") return EMPTY_CLONES; | |
| 35 | return Option.getOrElse(AsyncResult.value(result), () => "pending" as const); | |
| 36 | }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), | |
| 37 | ); | |
| 38 | ||
| 39 | const projectCloneAtom = Atom.family((key: string) => { | |
| 40 | const ref = parseScopedProjectKey(key); | |
| 41 | return Atom.make((get): ProjectCloneState => { | |
| 42 | if (ref === null) return null; | |
| 43 | const clones = get(environmentProjectClonesAtom(ref.environmentId)); | |
| 44 | if (clones === "pending") return "pending"; | |
| 45 | return clones.find((clone) => clone.projectId === ref.projectId) ?? null; | |
| 46 | }).pipe(Atom.withLabel(`mobile-project-clone:${key}`)); | |
| 47 | }); | |
| 48 | ||
| 49 | /** | |
| 50 | * The tracked clone for a project: `"pending"` before the stream's first | |
| 51 | * list, null once it finished or never started. | |
| 52 | */ | |
| 53 | export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState { | |
| 54 | return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); | |
| 55 | } |