1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { MaterialListRow } from "../../components/MaterialListRow";
3import { SettingsScreen } from "../settings/components/SettingsScreen";
4import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
5import { MaterialButton } from "../../components/MaterialButton";
6import {
7 addProjectRemoteSourceLabel,
8 addProjectRemoteSourcePathHint,
9 addProjectRemoteSourceProvider,
10 buildAddProjectRemoteSourceReadiness,
11 buildProjectCreateCommand,
12 canCreateProjectInEnvironment,
13 findExistingAddProject,
14 getAddProjectInitialQuery,
15 getCloneDestinationBrowsePath,
16 getCloneDestinationPath,
17 getCloneDirectoryName,
18 getDefaultCloneUrl,
19 getNewProjectPathPreview,
20 getNewProjectPublishTarget,
21 getNewProjectPublishTargets,
22 getNewProjectRepository,
23 normalizePastedCloneUrl,
24 parseAddProjectRemoteSource,
25 resolveAddProjectPath,
26 sortAddProjectProviderSources,
27 type AddProjectRemoteSource,
28} from "@t3tools/client-runtime/operations/projects";
29import {
30 connectionStatusText,
31 type EnvironmentConnectionPhase,
32} from "@t3tools/client-runtime/connection";
33import {
34 canPreloadBrowsePath,
35 createBrowseNavigationCoordinator,
36 filterFilesystemBrowseEntries,
37 getFilesystemBrowsePath,
38 resolveFilesystemReadAccess,
39} from "@t3tools/client-runtime/state/filesystem";
40import {
41 appendBrowsePathSegment,
42 inferProjectTitleFromPath,
43 isWindowsPlatform,
44} from "@t3tools/client-runtime/state/projects";
45import {
46 AuthOrchestrationOperateScope,
47 AuthSourceControlWriteScope,
48 AuthFilesystemReadScope,
49 CommandId,
50 type EnvironmentId,
51 type EnvironmentMachineKind,
52 ProjectId,
53 resolveEnvironmentMachineKind,
54 SourceControlProviderKind,
55} from "@t3tools/contracts";
56import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
57import { SymbolView } from "../../components/AppSymbol";
58import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
59import { Platform, Alert, Pressable, View } from "react-native";
60import { useSafeAreaInsets } from "react-native-safe-area-context";
61import * as Arr from "effect/Array";
62import * as Cause from "effect/Cause";
63import * as Order from "effect/Order";
64import { AsyncResult } from "effect/reactivity";
65import { cn } from "../../lib/cn";
66import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
67import { filesystemEnvironment } from "../../state/filesystem";
68import { projectEnvironment } from "../../state/projects";
69import { useEnvironmentQuery } from "../../state/query";
70import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
71import { useEnvironmentPresentation } from "../../state/presentation";
72import { sourceControlEnvironment } from "../../state/sourceControl";
73import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
74import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
75import { ErrorBanner } from "../../components/ErrorBanner";
76import { SourceControlIcon } from "../../components/SourceControlIcon";
77import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
78import { ThemedSwitch } from "../../components/ThemedSwitch";
79import { uuidv4 } from "../../lib/uuid";
80import { useAtomCommand } from "../../state/use-atom-command";
81import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
82import {
83 useRemoteConnectionStatus,
84 useRemoteEnvironmentRuntime,
85 useSavedRemoteConnections,
86} from "../../state/use-remote-environment-registry";
87import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
88import {
89 createNewTaskDraft,
90 setComposerDraftText,
91 waitForComposerDraftsLoaded,
92} from "../../state/use-composer-drafts";
93
94interface EnvironmentOption {
95 readonly environmentId: EnvironmentId;
96 readonly label: string;
97 readonly platform: string;
98 readonly machine: EnvironmentMachineKind;
99 readonly baseDirectory: string | null;
100 /** Folder for projects started from just a name; null on servers without it. */
101 readonly newProjectsRoot: string | null;
102 readonly connectionState: EnvironmentConnectionPhase;
103 readonly connectionError: string | null;
104 readonly connectionErrorTraceId: string | null;
105 /** Server runs clones in the background and streams progress; older servers block. */
106 readonly supportsCloneTracking: boolean;
107}
108
109const environmentOptionOrder = Order.mapInput(
110 Order.Struct({
111 label: Order.String,
112 }),
113 (environment: EnvironmentOption) => ({ label: environment.label }),
114);
115
116function platformFromOs(os: string | null | undefined): string {
117 if (os === "windows") return "Win32";
118 if (os === "darwin") return "MacIntel";
119 if (os === "linux") return "Linux";
120 return "";
121}
122
123function errorMessage(error: unknown): string {
124 return error instanceof Error && error.message.trim().length > 0
125 ? error.message
126 : "An error occurred.";
127}
128
129function stringParam(value: string | string[] | undefined): string | null {
130 if (Array.isArray(value)) return value[0] ?? null;
131 return value ?? null;
132}
133
134function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource {
135 return parseAddProjectRemoteSource(stringParam(value));
136}
137
138function SectionTitle(props: { readonly children: string }) {
139 return (
140 <Text
141 className={
142 Platform.OS === "android"
143 ? "px-4 text-sm font-t3-medium text-primary-text"
144 : "px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"
145 }
146 >
147 {props.children}
148 </Text>
149 );
150}
151
152function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) {
153 const insets = useSafeAreaInsets();
154
155 return (
156 // collapsable={false} is load-bearing: if this wrapper is flattened, the
157 // ScrollView lands directly under RNSSafeAreaView and RNS's formSheet
158 // scroll-view frame correction mistakes this full-height wrapper for a
159 // "header" sibling, coercing the ScrollView to zero height (blank sheet
160 // as soon as the sheet re-lays-out, e.g. when the keyboard opens).
161 <SettingsScreen title={props.title}>
162 <ScrollView
163 keyboardShouldPersistTaps="handled"
164 contentInsetAdjustmentBehavior="automatic"
165 contentContainerStyle={{
166 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
167 paddingTop: 16,
168 paddingBottom: Math.max(insets.bottom, 18) + 18,
169 gap: Platform.OS === "android" ? 16 : 10,
170 }}
171 >
172 {props.children}
173 </ScrollView>
174 </SettingsScreen>
175 );
176}
177
178function ListSection(props: { readonly children: ReactNode }) {
179 return (
180 <View
181 className={
182 Platform.OS === "android"
183 ? "overflow-hidden rounded-[28px] bg-grouped-card"
184 : "overflow-hidden rounded-[24px] bg-grouped-card"
185 }
186 >
187 {props.children}
188 </View>
189 );
190}
191
192function ListRow(props: {
193 readonly title: string;
194 readonly subtitle?: string | null;
195 readonly icon: ReactNode;
196 readonly disabled?: boolean;
197 readonly selected?: boolean;
198 readonly isFirst?: boolean;
199 readonly right?: ReactNode;
200 readonly onPress?: () => void;
201}) {
202 if (Platform.OS === "android") {
203 return (
204 <MaterialListRow
205 className="bg-grouped-card"
206 title={props.title}
207 subtitle={props.subtitle}
208 leading={props.icon}
209 trailing={props.right}
210 disabled={props.disabled}
211 onPress={props.onPress}
212 accessibilityRole={props.selected !== undefined ? "radio" : "button"}
213 accessibilityState={props.selected !== undefined ? { checked: props.selected } : undefined}
214 />
215 );
216 }
217 return (
218 <Pressable
219 disabled={props.disabled}
220 onPress={props.onPress}
221 className={cn(
222 "bg-grouped-card px-3.5 py-2.5 active:opacity-70",
223 !props.isFirst && "border-t border-border-subtle",
224 props.disabled && "opacity-[0.45]",
225 )}
226 >
227 <View className="flex-row items-center gap-3">
228 <View
229 className={
230 props.selected
231 ? "h-7 w-7 items-center justify-center rounded-full bg-primary"
232 : "h-7 w-7 items-center justify-center"
233 }
234 >
235 {props.icon}
236 </View>
237 <View className="flex-1 gap-0.5">
238 <Text className="text-base leading-snug font-t3-bold">{props.title}</Text>
239 {props.subtitle ? (
240 <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>
241 {props.subtitle}
242 </Text>
243 ) : null}
244 </View>
245 {"right" in props ? (
246 props.right
247 ) : !props.disabled ? (
248 <SymbolView
249 name="chevron.right"
250 size={13}
251 tintColorClassName="accent-chevron"
252 type="monochrome"
253 />
254 ) : null}
255 </View>
256 </Pressable>
257 );
258}
259
260function PrimaryActionButton(props: {
261 readonly label: string;
262 readonly disabled?: boolean;
263 readonly loading?: boolean;
264 readonly onPress: () => void;
265}) {
266 if (Platform.OS === "android") return <MaterialButton {...props} tone="primary" fullWidth />;
267 return (
268 <Pressable
269 disabled={props.disabled}
270 onPress={props.onPress}
271 className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45"
272 >
273 {props.loading ? (
274 <AppActivityIndicator colorClassName={String("accent-primary-foreground")} />
275 ) : (
276 <Text className="text-base font-t3-bold text-primary-foreground">{props.label}</Text>
277 )}
278 </Pressable>
279 );
280}
281
282function ProjectPathInput(props: {
283 readonly value: string;
284 readonly onChangeText: (value: string) => void;
285 readonly onSubmit: () => void;
286}) {
287 return (
288 <TextInput
289 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
290 value={props.value}
291 onChangeText={props.onChangeText}
292 autoCapitalize="none"
293 autoCorrect={false}
294 placeholder="~/projects/my-app"
295 returnKeyType="done"
296 onSubmitEditing={props.onSubmit}
297 />
298 );
299}
300
301// `pinnedDirectoryName` is the repository folder the clone destination keeps
302// appended to whatever folder the user browses to. The plain add-project flow
303// passes nothing, so it keeps proposing the browsed folder itself.
304function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirectoryName = "") {
305 const environmentId = environment?.environmentId ?? null;
306 const environmentBaseDirectory = environment?.baseDirectory ?? null;
307 const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? "");
308 const [pathInput, commitPathInput] = useState(() =>
309 getCloneDestinationPath(
310 getAddProjectInitialQuery(environmentBaseDirectory),
311 pinnedDirectoryName,
312 ),
313 );
314 const previousEnvironmentIdRef = useRef(environmentId);
315 const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
316 const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
317 reportFailure: false,
318 reportDefect: false,
319 });
320 const [browseNavigation] = useState(createBrowseNavigationCoordinator);
321 const [isBrowseNavigating, setIsBrowseNavigating] = useState(false);
322 const setPathInput = useCallback(
323 (path: string) => {
324 browseNavigation.invalidate();
325 setIsBrowseNavigating(false);
326 commitPathInput(path);
327 },
328 [browseNavigation],
329 );
330 const navigateToBrowsePath = useCallback(
331 async (input: {
332 readonly browseDirectoryPath: string;
333 readonly selectedDirectoryName?: string;
334 }) => {
335 const selectedDirectoryPath = input.selectedDirectoryName
336 ? appendBrowsePathSegment(input.browseDirectoryPath, input.selectedDirectoryName)
337 : input.browseDirectoryPath;
338 const nextPathInput =
339 pinnedDirectoryName && input.selectedDirectoryName
340 ? getCloneDestinationBrowsePath({
341 browseDirectoryPath: input.browseDirectoryPath,
342 selectedDirectoryName: input.selectedDirectoryName,
343 cloneDirectoryName: pinnedDirectoryName,
344 caseSensitive: clonePathCaseSensitive,
345 })
346 : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName);
347 setIsBrowseNavigating(true);
348 const committed = await browseNavigation.run(
349 async () => {
350 if (
351 environment &&
352 readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) &&
353 canPreloadBrowsePath(environmentRuntime?.connectionState)
354 ) {
355 await loadBrowsePath({
356 environmentId: environment.environmentId,
357 input: { partialPath: selectedDirectoryPath },
358 });
359 }
360 },
361 () => commitPathInput(nextPathInput),
362 );
363 if (committed) {
364 setIsBrowseNavigating(false);
365 }
366 return committed;
367 },
368 [
369 browseNavigation,
370 clonePathCaseSensitive,
371 environment,
372 environmentRuntime?.connectionState,
373 loadBrowsePath,
374 pinnedDirectoryName,
375 ],
376 );
377
378 useEffect(() => {
379 if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) {
380 previousEnvironmentIdRef.current = environmentId;
381 setPathInput(
382 getCloneDestinationPath(
383 getAddProjectInitialQuery(environmentBaseDirectory),
384 pinnedDirectoryName,
385 ),
386 );
387 }
388 }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]);
389
390 useEffect(
391 () => () => {
392 browseNavigation.invalidate();
393 },
394 [browseNavigation],
395 );
396
397 return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath };
398}
399
400function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
401 const serverConfigByEnvironmentId = useServerConfigs();
402 const { savedConnectionsById } = useSavedRemoteConnections();
403 const { connectedEnvironments } = useRemoteConnectionStatus();
404
405 return useMemo<ReadonlyArray<EnvironmentOption>>(() => {
406 const runtimeByEnvironmentId = new Map(
407 connectedEnvironments.map((environment) => [environment.environmentId, environment] as const),
408 );
409 const options = Object.values(savedConnectionsById).map((connection) => {
410 const config = serverConfigByEnvironmentId.get(connection.environmentId);
411 const runtime = runtimeByEnvironmentId.get(connection.environmentId);
412 return {
413 environmentId: connection.environmentId,
414 label: connection.environmentLabel,
415 platform: platformFromOs(config?.environment.platform.os ?? null),
416 machine: resolveEnvironmentMachineKind(config ?? null),
417 baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
418 newProjectsRoot: config?.newProjectsRoot ?? null,
419 connectionState: runtime?.connectionState ?? "available",
420 connectionError: runtime?.connectionError ?? null,
421 connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
422 supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true,
423 };
424 });
425 return Arr.sort(
426 options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)),
427 environmentOptionOrder,
428 );
429 }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]);
430}
431
432function useSelectedEnvironment(): {
433 readonly environmentOptions: ReadonlyArray<EnvironmentOption>;
434 readonly selectedEnvironment: EnvironmentOption | null;
435 readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
436} {
437 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
438 const environmentOptions = useEnvironmentOptions();
439 const selectedEnvironment =
440 environmentOptions.find(
441 (environment) =>
442 environment.environmentId === selectedEnvironmentId &&
443 canCreateProjectInEnvironment(environment.connectionState),
444 ) ??
445 environmentOptions.find((environment) =>
446 canCreateProjectInEnvironment(environment.connectionState),
447 ) ??
448 null;
449
450 return {
451 environmentOptions,
452 selectedEnvironment,
453 setSelectedEnvironmentId,
454 };
455}
456
457function EmptyEnvironmentState() {
458 const navigation = useNavigation();
459
460 return (
461 <View className="items-center gap-3 rounded-2xl bg-grouped-card px-5 py-8">
462 <Text className="text-center text-lg font-t3-bold">Environment unavailable</Text>
463 <Text className="text-center text-sm leading-normal text-foreground-muted">
464 Start or reconnect an environment before adding a project.
465 </Text>
466 <Pressable
467 onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}
468 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
469 >
470 <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>
471 </Pressable>
472 </View>
473 );
474}
475
476function SourceControlRow(props: {
477 readonly source: AddProjectRemoteSource;
478 readonly selectedEnvironmentId: EnvironmentId;
479 readonly ready: boolean;
480 readonly hint: string;
481 readonly isFirst: boolean;
482}) {
483 const navigation = useNavigation();
484 const title =
485 props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
486 const subtitle =
487 props.source === "url"
488 ? "Clone from a remote URL"
489 : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;
490 const icon =
491 props.source === "url" ? (
492 <SymbolView
493 name="link"
494 size={Platform.OS === "android" ? 24 : 17}
495 tintColorClassName="accent-icon"
496 type="monochrome"
497 />
498 ) : (
499 <SourceControlIcon
500 kind={sourceControlClients.get(props.source).icon}
501 size={Platform.OS === "android" ? 24 : 18}
502 colorClassName="accent-icon"
503 />
504 );
505
506 if (!props.ready) {
507 return (
508 <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} />
509 );
510 }
511
512 return (
513 <ListRow
514 title={title}
515 subtitle={subtitle}
516 icon={icon}
517 isFirst={props.isFirst}
518 onPress={() =>
519 navigation.dispatch(
520 StackActions.push("AddProjectRepository", {
521 environmentId: props.selectedEnvironmentId,
522 source: props.source,
523 }),
524 )
525 }
526 />
527 );
528}
529
530export function AddProjectSourceScreen() {
531 const navigation = useNavigation();
532 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
533 useSelectedEnvironment();
534 const canWriteSourceControl = useEnvironmentScope(
535 selectedEnvironment?.environmentId ?? null,
536 AuthSourceControlWriteScope,
537 );
538 const canCreateProject = useEnvironmentScope(
539 selectedEnvironment?.environmentId ?? null,
540 AuthOrchestrationOperateScope,
541 );
542 const canCloneProject = canWriteSourceControl && canCreateProject;
543 const discoveryState = useEnvironmentQuery(
544 selectedEnvironment === null
545 ? null
546 : sourceControlEnvironment.discovery({
547 environmentId: selectedEnvironment.environmentId,
548 input: {},
549 }),
550 );
551 const readiness = useMemo(
552 () => buildAddProjectRemoteSourceReadiness(discoveryState.data),
553 [discoveryState.data],
554 );
555
556 return (
557 <AddProjectShell title="Add project">
558 {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}
559
560 {environmentOptions.length > 1 ? (
561 <>
562 <SectionTitle>Environments</SectionTitle>
563 <ListSection>
564 {environmentOptions.map((environment, index) => (
565 <ListRow
566 key={environment.environmentId}
567 title={environment.label}
568 subtitle={
569 canCreateProjectInEnvironment(environment.connectionState)
570 ? undefined
571 : connectionStatusText({
572 phase: environment.connectionState,
573 error: environment.connectionError,
574 traceId: environment.connectionErrorTraceId,
575 })
576 }
577 icon={
578 <EnvironmentMachineSymbol
579 kind={environment.machine}
580 size={Platform.OS === "android" ? 24 : 17}
581 tintColorClassName="accent-icon"
582 />
583 }
584 selected={environment.environmentId === selectedEnvironment?.environmentId}
585 disabled={!canCreateProjectInEnvironment(environment.connectionState)}
586 isFirst={index === 0}
587 right={
588 environment.environmentId === selectedEnvironment?.environmentId ? (
589 <SymbolView
590 name="checkmark"
591 size={Platform.OS === "android" ? 20 : 14}
592 tintColorClassName="accent-icon"
593 type="monochrome"
594 />
595 ) : null
596 }
597 onPress={() => setSelectedEnvironmentId(environment.environmentId)}
598 />
599 ))}
600 </ListSection>
601 </>
602 ) : null}
603
604 {selectedEnvironment ? (
605 <>
606 <ListSection>
607 {selectedEnvironment.newProjectsRoot !== null ? (
608 <ListRow
609 title="New project"
610 subtitle="Start a new Git repository from a name"
611 icon={
612 <SymbolView
613 name="plus"
614 size={Platform.OS === "android" ? 24 : 17}
615 tintColorClassName="accent-icon"
616 type="monochrome"
617 />
618 }
619 isFirst
620 onPress={() =>
621 navigation.dispatch(
622 StackActions.push("AddProjectNew", {
623 environmentId: selectedEnvironment.environmentId,
624 }),
625 )
626 }
627 />
628 ) : null}
629 <ListRow
630 title="Local folder"
631 subtitle={
632 canCreateProject
633 ? "Browse a folder on disk"
634 : "This connection cannot add projects."
635 }
636 icon={
637 <SymbolView
638 name="folder.badge.plus"
639 size={Platform.OS === "android" ? 24 : 17}
640 tintColorClassName="accent-icon"
641 type="monochrome"
642 />
643 }
644 isFirst={selectedEnvironment.newProjectsRoot === null}
645 disabled={!canCreateProject}
646 onPress={() =>
647 navigation.dispatch(
648 StackActions.push("AddProjectLocal", {
649 environmentId: selectedEnvironment.environmentId,
650 }),
651 )
652 }
653 />
654 {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
655 (candidate) => (
656 <SourceControlRow
657 key={candidate}
658 source={candidate}
659 selectedEnvironmentId={selectedEnvironment.environmentId}
660 ready={canCloneProject && readiness(candidate).ready}
661 hint={
662 !canCloneProject
663 ? "This connection cannot clone projects."
664 : readiness(candidate).ready
665 ? addProjectRemoteSourcePathHint(candidate)
666 : (readiness(candidate).hint ?? "")
667 }
668 isFirst={false}
669 />
670 ),
671 )}
672 </ListSection>
673 {discoveryState.isPending ? (
674 <AppActivityIndicator colorClassName="accent-icon-muted" />
675 ) : null}
676 </>
677 ) : null}
678 </AddProjectShell>
679 );
680}
681
682function openNewTaskDraft(
683 navigation: { dispatch: (action: ReturnType<typeof CommonActions.reset>) => void },
684 params: {
685 environmentId: EnvironmentId;
686 projectId: ProjectId;
687 title: string;
688 cloning?: "1";
689 draftId?: string;
690 },
691) {
692 navigation.dispatch(
693 CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),
694 );
695}
696
697/** A new task draft in the project, holding a prompt link's prompt. */
698async function draftWithPrompt(
699 environmentId: EnvironmentId,
700 projectId: ProjectId,
701 prompt: string,
702): Promise<string> {
703 await waitForComposerDraftsLoaded();
704 const draftId = createNewTaskDraft({ environmentId, projectId });
705 setComposerDraftText(draftId, prompt);
706 return draftId;
707}
708
709function useCreateProject(environment: EnvironmentOption | null) {
710 const navigation = useNavigation();
711 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
712 const projects = useProjects();
713
714 return useCallback(
715 async (workspaceRoot: string) => {
716 if (
717 !environment ||
718 !canCreateProjectInEnvironment(environment.connectionState) ||
719 !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope)
720 ) {
721 Alert.alert(
722 "Project not added",
723 `This connection cannot add projects right now. Any existing files remain at ${workspaceRoot}.`,
724 );
725 return;
726 }
727
728 const existing = findExistingAddProject({
729 projects,
730 environmentId: environment.environmentId,
731 path: workspaceRoot,
732 });
733 if (existing) {
734 Alert.alert("Project already exists", existing.title);
735 navigation.dispatch(
736 CommonActions.reset({
737 index: 0,
738 routes: [
739 {
740 name: "NewTaskDraft",
741 params: {
742 environmentId: existing.environmentId,
743 projectId: existing.id,
744 title: existing.title,
745 },
746 },
747 ],
748 }),
749 );
750 return;
751 }
752
753 const projectId = ProjectId.make(uuidv4());
754 const command = buildProjectCreateCommand({
755 commandId: CommandId.make(uuidv4()),
756 projectId,
757 workspaceRoot,
758 });
759 const result = await createProject({
760 environmentId: environment.environmentId,
761 input: command,
762 });
763 if (AsyncResult.isFailure(result)) {
764 return result;
765 }
766 navigation.dispatch(
767 CommonActions.reset({
768 index: 0,
769 routes: [
770 {
771 name: "NewTaskDraft",
772 params: {
773 environmentId: environment.environmentId,
774 projectId,
775 title: inferProjectTitleFromPath(workspaceRoot),
776 },
777 },
778 ],
779 }),
780 );
781 return result;
782 },
783 [createProject, environment, projects, navigation],
784 );
785}
786
787function useEnvironmentFromParam(
788 environmentIdParam: string | string[] | undefined,
789): EnvironmentOption | null {
790 const environmentOptions = useEnvironmentOptions();
791 const environmentId = stringParam(environmentIdParam) as EnvironmentId | null;
792 return resolveAddProjectEnvironment(environmentOptions, environmentId);
793}
794
795export function AddProjectRepositoryScreen(props: {
796 readonly environmentId?: string | string[];
797 readonly source?: string | string[];
798}) {
799 const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, {
800 reportFailure: false,
801 });
802 const navigation = useNavigation();
803 const environment = useEnvironmentFromParam(props.environmentId);
804 const source = sourceFromParam(props.source);
805 const [repositoryInput, setRepositoryInput] = useState("");
806 const [isSubmitting, setIsSubmitting] = useState(false);
807 const [error, setError] = useState<string | null>(null);
808
809 const lookupRepository = useCallback(async () => {
810 if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return;
811 setError(null);
812 setIsSubmitting(true);
813 const provider = addProjectRemoteSourceProvider(source);
814 if (!provider) {
815 const remoteUrl = normalizePastedCloneUrl(repositoryInput);
816 navigation.dispatch(
817 StackActions.push("AddProjectDestination", {
818 environmentId: environment.environmentId,
819 source,
820 remoteUrl,
821 repositoryTitle: remoteUrl,
822 repositoryName: getCloneDirectoryName(remoteUrl),
823 }),
824 );
825 setIsSubmitting(false);
826 return;
827 }
828
829 const result = await lookupRepositoryQuery({
830 environmentId: environment.environmentId,
831 input: {
832 provider,
833 repository: repositoryInput.trim(),
834 },
835 });
836 if (AsyncResult.isFailure(result)) {
837 setError(errorMessage(Cause.squash(result.cause)));
838 } else {
839 const repository = result.value;
840 navigation.dispatch(
841 StackActions.push("AddProjectDestination", {
842 environmentId: environment.environmentId,
843 source,
844 remoteUrl: getDefaultCloneUrl(repository),
845 repositoryTitle: repository.nameWithOwner,
846 repositoryName: getCloneDirectoryName(repository.nameWithOwner),
847 }),
848 );
849 }
850 setIsSubmitting(false);
851 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
852
853 return (
854 <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
855 {error ? <ErrorBanner message={error} /> : null}
856 {environment ? (
857 <>
858 <TextInput
859 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
860 value={repositoryInput}
861 onChangeText={setRepositoryInput}
862 autoCapitalize="none"
863 autoCorrect={false}
864 placeholder={
865 source === "url"
866 ? "https://github.com/org/repo.git"
867 : addProjectRemoteSourcePathHint(source)
868 }
869 returnKeyType="next"
870 onSubmitEditing={() => void lookupRepository()}
871 />
872 <PrimaryActionButton
873 label={source === "url" ? "Continue" : "Lookup repository"}
874 disabled={isSubmitting || repositoryInput.trim().length === 0}
875 onPress={() => void lookupRepository()}
876 loading={isSubmitting}
877 />
878 </>
879 ) : (
880 <EmptyEnvironmentState />
881 )}
882 </AddProjectShell>
883 );
884}
885
886function FolderBrowser(props: {
887 readonly environment: EnvironmentOption;
888 readonly pathInput: string;
889 readonly setPathInput: (path: string) => void;
890 readonly navigateToBrowsePath: (input: {
891 readonly browseDirectoryPath: string;
892 readonly selectedDirectoryName?: string;
893 }) => Promise<boolean>;
894 readonly pinnedDirectoryName?: string;
895}) {
896 const browsePath = useMemo(
897 () => getFilesystemBrowsePath(props.pathInput, props.environment.platform),
898 [props.environment.platform, props.pathInput],
899 );
900 const browseInput = useMemo(
901 () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null),
902 [browsePath.directoryPath],
903 );
904 const fileAccessSession = useEnvironmentQuery(
905 environmentSession.sessionStateAtom(props.environment.environmentId),
906 );
907 const fileEnvironment = useEnvironmentPresentation(props.environment.environmentId);
908 const fileAccess = resolveFilesystemReadAccess({
909 isCatalogReady: fileEnvironment.isReady,
910 connection: fileEnvironment.presentation?.connection ?? null,
911 session: fileAccessSession.data,
912 sessionError: fileAccessSession.error,
913 });
914 const { canReadFiles } = fileAccess;
915 const browseState = useEnvironmentQuery(
916 !canReadFiles || browseInput === null
917 ? null
918 : filesystemEnvironment.browse({
919 environmentId: props.environment.environmentId,
920 input: browseInput,
921 }),
922 );
923 // A pinned repository folder does not exist yet, so filtering the listing by
924 // it would empty the folder picker. Anything the user typed still filters.
925 const pinnedDirectoryName = props.pinnedDirectoryName ?? "";
926 const pinnedDirectoryMatches = isWindowsPlatform(props.environment.platform)
927 ? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase()
928 : browsePath.filterQuery === pinnedDirectoryName;
929 const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery;
930 const { visibleEntries: visibleBrowseEntries } = useMemo(
931 () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery),
932 [browseFilterQuery, browseState.data?.entries],
933 );
934
935 return (
936 <>
937 <SectionTitle>Browse folders</SectionTitle>
938 {!canReadFiles && !fileAccess.isPending ? (
939 <ErrorBanner message={fileAccess.error ?? "This connection cannot browse host folders."} />
940 ) : null}
941 {browseState.error ? <ErrorBanner message={browseState.error} /> : null}
942 <ListSection>
943 {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? (
944 <View className="items-center py-5">
945 <AppActivityIndicator colorClassName="accent-icon-muted" />
946 </View>
947 ) : null}
948 {browsePath.canBrowseUp ? (
949 <ListRow
950 title=".."
951 icon={
952 <SymbolView
953 name="arrow.turn.left.up"
954 size={Platform.OS === "android" ? 24 : 17}
955 tintColorClassName="accent-icon-muted"
956 type="monochrome"
957 />
958 }
959 isFirst
960 right={null}
961 onPress={() => {
962 if (browsePath.parentPath) {
963 void props.navigateToBrowsePath({
964 browseDirectoryPath: browsePath.parentPath,
965 });
966 }
967 }}
968 />
969 ) : null}
970 {visibleBrowseEntries.map((entry, index) => (
971 <ListRow
972 key={entry.fullPath}
973 title={entry.name}
974 icon={
975 <SymbolView
976 name="folder"
977 size={Platform.OS === "android" ? 24 : 17}
978 tintColorClassName="accent-icon-muted"
979 type="monochrome"
980 />
981 }
982 isFirst={index === 0 && !browsePath.canBrowseUp}
983 right={null}
984 onPress={() => {
985 void props.navigateToBrowsePath({
986 browseDirectoryPath: browsePath.directoryPath,
987 selectedDirectoryName: entry.name,
988 });
989 }}
990 />
991 ))}
992 </ListSection>
993 </>
994 );
995}
996
997/**
998 * New project: a name, then the server makes the folder, README, icon, and
999 * first commit. Optionally publishes it to GitHub as a private repository.
1000 */
1001export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) {
1002 const navigation = useNavigation();
1003 // Starts on the machine picked in Add project; the rows below switch it.
1004 const environmentOptions = useEnvironmentOptions().filter(
1005 (option) => option.newProjectsRoot !== null,
1006 );
1007 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(
1008 () => stringParam(props.environmentId) as EnvironmentId | null,
1009 );
1010 const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId);
1011 const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
1012 const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
1013 reportFailure: false,
1014 });
1015 const discoveryState = useEnvironmentQuery(
1016 environment === null
1017 ? null
1018 : sourceControlEnvironment.discovery({
1019 environmentId: environment.environmentId,
1020 input: {},
1021 }),
1022 );
1023 const publishTargets = getNewProjectPublishTargets(discoveryState.data);
1024 const [name, setName] = useState("");
1025 const [publishes, setPublishes] = useState(false);
1026 // Null takes the first ready host, GitHub when it is ready.
1027 const [publishKind, setPublishKind] = useState<SourceControlProviderKind | null>(null);
1028 const publishTarget = getNewProjectPublishTarget(publishTargets, publishKind);
1029 const [isSubmitting, setIsSubmitting] = useState(false);
1030 const [error, setError] = useState<string | null>(null);
1031 const trimmedName = name.trim();
1032 const pathPreview =
1033 environment?.newProjectsRoot != null
1034 ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName)
1035 : null;
1036
1037 // Shown when there is a choice, or when the selected machine went away and
1038 // another one can take over.
1039 const showMachines =
1040 environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0);
1041 const machineRows = showMachines ? (
1042 <ListSection>
1043 {environmentOptions.map((option, index) => {
1044 const selected = option.environmentId === environment?.environmentId;
1045 return (
1046 <ListRow
1047 key={option.environmentId}
1048 title={option.label}
1049 icon={
1050 <EnvironmentMachineSymbol
1051 kind={option.machine}
1052 size={Platform.OS === "android" ? 24 : 17}
1053 tintColorClassName="accent-icon"
1054 />
1055 }
1056 selected={selected}
1057 // The create in flight keeps the machine it started on.
1058 disabled={isSubmitting}
1059 isFirst={index === 0}
1060 right={
1061 selected ? (
1062 <SymbolView
1063 name="checkmark"
1064 size={Platform.OS === "android" ? 20 : 14}
1065 tintColorClassName="accent-icon"
1066 type="monochrome"
1067 />
1068 ) : null
1069 }
1070 onPress={() => setSelectedEnvironmentId(option.environmentId)}
1071 />
1072 );
1073 })}
1074 </ListSection>
1075 ) : null;
1076
1077 // State lags a render behind, so a double tap could start a second create.
1078 const submittingRef = useRef(false);
1079 const submit = async () => {
1080 if (!environment || trimmedName.length === 0 || submittingRef.current) return;
1081 submittingRef.current = true;
1082 setError(null);
1083 setIsSubmitting(true);
1084 try {
1085 const result = await createNew({
1086 environmentId: environment.environmentId,
1087 input: { name: trimmedName },
1088 });
1089 if (AsyncResult.isFailure(result)) {
1090 setError(errorMessage(Cause.squash(result.cause)));
1091 return;
1092 }
1093 const { projectId, workspaceRoot, commitError } = result.value;
1094 if (commitError !== undefined) {
1095 Alert.alert("Created without a first commit", commitError);
1096 }
1097 if (publishes && publishTarget !== null) {
1098 void publishRepository({
1099 environmentId: environment.environmentId,
1100 input: {
1101 cwd: workspaceRoot,
1102 provider: publishTarget.definition.kind,
1103 repository: getNewProjectRepository(publishTarget, workspaceRoot),
1104 visibility: "private",
1105 },
1106 }).then((publishResult) => {
1107 if (AsyncResult.isFailure(publishResult)) {
1108 Alert.alert(
1109 `Could not create the ${publishTarget.definition.label} repository`,
1110 errorMessage(Cause.squash(publishResult.cause)),
1111 );
1112 }
1113 });
1114 }
1115 // The draft screen resolves its project from the client store, so it
1116 // must not open before the create event has arrived.
1117 const project = await waitForProject(
1118 { environmentId: environment.environmentId, projectId },
1119 15_000,
1120 );
1121 if (project === null) {
1122 // The project exists, so clearing the name keeps Create from making a `-2` copy.
1123 setName("");
1124 setError(
1125 "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
1126 );
1127 return;
1128 }
1129 openNewTaskDraft(navigation, {
1130 environmentId: environment.environmentId,
1131 projectId,
1132 title: trimmedName,
1133 });
1134 } finally {
1135 submittingRef.current = false;
1136 setIsSubmitting(false);
1137 }
1138 };
1139
1140 return (
1141 <AddProjectShell title="New project">
1142 {error ? <ErrorBanner message={error} /> : null}
1143 {environment ? (
1144 <>
1145 <TextInput
1146 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
1147 value={name}
1148 onChangeText={setName}
1149 autoCorrect={false}
1150 autoFocus
1151 placeholder="Project name"
1152 returnKeyType="done"
1153 onSubmitEditing={() => void submit()}
1154 />
1155 {pathPreview !== null ? (
1156 <Text className="px-1 text-sm leading-snug text-foreground-muted" numberOfLines={2}>
1157 {trimmedName.length > 0
1158 ? `Creates ${pathPreview}`
1159 : `Goes in ${environment.newProjectsRoot}`}
1160 {showMachines ? ` on ${environment.label}` : null}
1161 </Text>
1162 ) : null}
1163 {machineRows}
1164 {publishTarget !== null ? (
1165 <ListSection>
1166 <ListRow
1167 title={`Create private repository on ${publishTarget.definition.label}`}
1168 subtitle={
1169 trimmedName.length > 0 && pathPreview !== null
1170 ? getNewProjectRepository(publishTarget, pathPreview)
1171 : publishTarget.owner
1172 }
1173 icon={
1174 <SourceControlIcon
1175 kind={publishTarget.definition.icon}
1176 size={Platform.OS === "android" ? 24 : 18}
1177 colorClassName="accent-icon"
1178 />
1179 }
1180 isFirst
1181 right={
1182 <ThemedSwitch
1183 accessibilityLabel={`Create private repository on ${publishTarget.definition.label}`}
1184 value={publishes}
1185 onValueChange={setPublishes}
1186 />
1187 }
1188 onPress={() => setPublishes((current) => !current)}
1189 />
1190 {publishes && publishTargets.length > 1
1191 ? publishTargets.map((target) => {
1192 const selected = target.definition.kind === publishTarget.definition.kind;
1193 return (
1194 <ListRow
1195 key={target.definition.kind}
1196 title={target.definition.label}
1197 icon={
1198 <SourceControlIcon
1199 kind={target.definition.icon}
1200 size={Platform.OS === "android" ? 24 : 17}
1201 colorClassName="accent-icon"
1202 />
1203 }
1204 selected={selected}
1205 disabled={isSubmitting}
1206 right={
1207 selected ? (
1208 <SymbolView
1209 name="checkmark"
1210 size={Platform.OS === "android" ? 20 : 14}
1211 tintColorClassName="accent-icon"
1212 type="monochrome"
1213 />
1214 ) : null
1215 }
1216 onPress={() => setPublishKind(target.definition.kind)}
1217 />
1218 );
1219 })
1220 : null}
1221 </ListSection>
1222 ) : null}
1223 <PrimaryActionButton
1224 label="Create project"
1225 disabled={isSubmitting || trimmedName.length === 0}
1226 onPress={() => void submit()}
1227 loading={isSubmitting}
1228 />
1229 <ListSection>
1230 <ListRow
1231 title="Add existing project"
1232 subtitle="Open a folder or clone a repository"
1233 icon={
1234 <SymbolView
1235 name="folder.badge.plus"
1236 size={Platform.OS === "android" ? 24 : 17}
1237 tintColorClassName="accent-icon"
1238 type="monochrome"
1239 />
1240 }
1241 isFirst
1242 // New project opens from Add project, so going back shows the
1243 // other sources. A deep link has nothing behind it.
1244 onPress={() =>
1245 navigation.canGoBack()
1246 ? navigation.goBack()
1247 : navigation.dispatch(StackActions.replace("AddProject"))
1248 }
1249 />
1250 </ListSection>
1251 </>
1252 ) : environmentOptions.length > 0 ? (
1253 machineRows
1254 ) : (
1255 <EmptyEnvironmentState />
1256 )}
1257 </AddProjectShell>
1258 );
1259}
1260
1261export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
1262 const environment = useEnvironmentFromParam(props.environmentId);
1263 const canCreateProject = useEnvironmentScope(
1264 environment?.environmentId ?? null,
1265 AuthOrchestrationOperateScope,
1266 );
1267 const createProject = useCreateProject(environment);
1268 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
1269 useBrowsePathInput(environment);
1270 const [isSubmitting, setIsSubmitting] = useState(false);
1271 const [error, setError] = useState<string | null>(null);
1272
1273 const submitPath = useCallback(async () => {
1274 if (!environment || isBrowseNavigating || isSubmitting) return;
1275 setError(null);
1276 const resolved = resolveAddProjectPath({
1277 rawPath: pathInput,
1278 currentProjectCwd: null,
1279 platform: environment.platform,
1280 });
1281 if (!resolved.ok) {
1282 setError(resolved.error);
1283 return;
1284 }
1285
1286 setIsSubmitting(true);
1287 const result = await createProject(resolved.path);
1288 if (result && AsyncResult.isFailure(result)) {
1289 setError(errorMessage(Cause.squash(result.cause)));
1290 }
1291 setIsSubmitting(false);
1292 }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]);
1293
1294 return (
1295 <AddProjectShell title="Local folder">
1296 {error ? <ErrorBanner message={error} /> : null}
1297 {environment ? (
1298 <>
1299 {!canCreateProject ? (
1300 <ErrorBanner message="This connection cannot add projects." />
1301 ) : null}
1302 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
1303 <PrimaryActionButton
1304 label="Add project"
1305 disabled={!canCreateProject || isBrowseNavigating || isSubmitting}
1306 onPress={submitPath}
1307 loading={isSubmitting}
1308 />
1309 <FolderBrowser
1310 environment={environment}
1311 navigateToBrowsePath={navigateToBrowsePath}
1312 pathInput={pathInput}
1313 setPathInput={setPathInput}
1314 />
1315 </>
1316 ) : (
1317 <EmptyEnvironmentState />
1318 )}
1319 </AddProjectShell>
1320 );
1321}
1322
1323export function AddProjectDestinationScreen(props: {
1324 readonly environmentId?: string | string[];
1325 readonly remoteUrl?: string | string[];
1326 readonly repositoryTitle?: string | string[];
1327 readonly repositoryName?: string | string[];
1328 readonly prompt?: string | string[];
1329}) {
1330 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
1331 reportFailure: false,
1332 });
1333 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
1334 reportFailure: false,
1335 });
1336 const navigation = useNavigation();
1337 const environment = useEnvironmentFromParam(props.environmentId);
1338 const canWriteSourceControl = useEnvironmentScope(
1339 environment?.environmentId ?? null,
1340 AuthSourceControlWriteScope,
1341 );
1342 const canCreateProject = useEnvironmentScope(
1343 environment?.environmentId ?? null,
1344 AuthOrchestrationOperateScope,
1345 );
1346 const canCloneProject = canWriteSourceControl && canCreateProject;
1347 const createProject = useCreateProject(environment);
1348 const remoteUrl = stringParam(props.remoteUrl);
1349 const repositoryTitle = stringParam(props.repositoryTitle);
1350 const prompt = stringParam(props.prompt);
1351 // A lookup derives this from "owner/repo", a pasted clone URL from its own
1352 // last segment. Older links without the param keep the browsed folder.
1353 // Trim once here: the path input and the folder-list filter must compare the
1354 // same value, or a deep link with a padded param empties the folder picker.
1355 const repositoryName = stringParam(props.repositoryName)?.trim() ?? "";
1356 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(
1357 environment,
1358 repositoryName,
1359 );
1360 const [isSubmitting, setIsSubmitting] = useState(false);
1361 const [error, setError] = useState<string | null>(null);
1362
1363 const submitPath = useCallback(async () => {
1364 if (
1365 !environment ||
1366 !readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
1367 !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
1368 !remoteUrl ||
1369 isBrowseNavigating ||
1370 isSubmitting
1371 ) {
1372 return;
1373 }
1374 setError(null);
1375 const resolved = resolveAddProjectPath({
1376 rawPath: pathInput,
1377 currentProjectCwd: null,
1378 platform: environment.platform,
1379 });
1380 if (!resolved.ok) {
1381 setError(resolved.error);
1382 return;
1383 }
1384
1385 setIsSubmitting(true);
1386 if (environment.supportsCloneTracking) {
1387 // The server creates the project and clones in the background; the
1388 // draft screen shows progress and holds Start until the files land.
1389 const projectId = ProjectId.make(uuidv4());
1390 const title = inferProjectTitleFromPath(resolved.path);
1391 const startResult = await startProjectClone({
1392 environmentId: environment.environmentId,
1393 input: {
1394 projectId,
1395 title,
1396 createdAt: new Date().toISOString(),
1397 remoteUrl,
1398 destinationPath: resolved.path,
1399 },
1400 });
1401 if (AsyncResult.isFailure(startResult)) {
1402 setError(errorMessage(Cause.squash(startResult.cause)));
1403 } else {
1404 // The draft screen resolves its project from the client store, so it
1405 // must not open before the create event has arrived (it would fall
1406 // back to the project picker and lose the clone controls). Stay in
1407 // the submitting state until then; the clone keeps running either way.
1408 const project = await waitForProject(
1409 { environmentId: environment.environmentId, projectId },
1410 15_000,
1411 );
1412 if (project === null) {
1413 setError(
1414 "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
1415 );
1416 } else {
1417 openNewTaskDraft(navigation, {
1418 environmentId: environment.environmentId,
1419 projectId,
1420 title,
1421 cloning: "1",
1422 ...(prompt
1423 ? // Without its prompt the draft still opens on the cloned project.
1424 await draftWithPrompt(environment.environmentId, projectId, prompt).then(
1425 (draftId) => ({ draftId }),
1426 () => ({}),
1427 )
1428 : {}),
1429 });
1430 }
1431 }
1432 setIsSubmitting(false);
1433 return;
1434 }
1435 const cloneResult = await cloneRepository({
1436 environmentId: environment.environmentId,
1437 input: {
1438 remoteUrl,
1439 destinationPath: resolved.path,
1440 },
1441 });
1442 if (AsyncResult.isFailure(cloneResult)) {
1443 setError(errorMessage(Cause.squash(cloneResult.cause)));
1444 } else {
1445 const createResult = await createProject(cloneResult.value.cwd);
1446 if (createResult && AsyncResult.isFailure(createResult)) {
1447 setError(errorMessage(Cause.squash(createResult.cause)));
1448 }
1449 }
1450 setIsSubmitting(false);
1451 }, [
1452 cloneRepository,
1453 createProject,
1454 environment,
1455 isBrowseNavigating,
1456 isSubmitting,
1457 navigation,
1458 pathInput,
1459 prompt,
1460 remoteUrl,
1461 startProjectClone,
1462 ]);
1463
1464 return (
1465 <AddProjectShell title="Clone destination">
1466 {error ? <ErrorBanner message={error} /> : null}
1467 {repositoryTitle ? (
1468 <View className="rounded-[24px] bg-grouped-card px-4 py-3">
1469 <Text className="text-base font-t3-bold">{repositoryTitle}</Text>
1470 <Text className="mt-0.5 text-xs text-foreground-muted" numberOfLines={2}>
1471 {remoteUrl}
1472 </Text>
1473 </View>
1474 ) : null}
1475 {environment ? (
1476 <>
1477 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
1478 <PrimaryActionButton
1479 label="Clone project"
1480 disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
1481 onPress={submitPath}
1482 loading={isSubmitting}
1483 />
1484 {!canCloneProject ? (
1485 <Text className="text-sm text-foreground-muted">
1486 This connection cannot clone projects.
1487 </Text>
1488 ) : null}
1489 <FolderBrowser
1490 environment={environment}
1491 navigateToBrowsePath={navigateToBrowsePath}
1492 pathInput={pathInput}
1493 setPathInput={setPathInput}
1494 pinnedDirectoryName={repositoryName}
1495 />
1496 </>
1497 ) : (
1498 <EmptyEnvironmentState />
1499 )}
1500 </AddProjectShell>
1501 );
1502}