import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource,} from "@t3tools/client-runtime/operations/projects";import { connectionStatusText, type EnvironmentConnectionPhase,} from "@t3tools/client-runtime/connection";import { canPreloadBrowsePath, createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath,} from "@t3tools/client-runtime/state/filesystem";import { appendBrowsePathSegment, inferProjectTitleFromPath, isWindowsPlatform,} from "@t3tools/client-runtime/state/projects";import { CommandId, type EnvironmentId, type EnvironmentMachineKind, ProjectId, resolveEnvironmentMachineKind,} from "@t3tools/contracts";import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";import { SymbolView } from "../../components/AppSymbol";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import * as Arr from "effect/Array";import * as Cause from "effect/Cause";import * as Order from "effect/Order";import { AsyncResult } from "effect/unstable/reactivity";import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs } from "../../state/entities";import { filesystemEnvironment } from "../../state/filesystem";import { projectEnvironment } from "../../state/projects";import { useEnvironmentQuery } from "../../state/query";import { sourceControlEnvironment } from "../../state/sourceControl";import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";import { ErrorBanner } from "../../components/ErrorBanner";import { SourceControlIcon } from "../../components/SourceControlIcon";import { uuidv4 } from "../../lib/uuid";import { useAtomCommand } from "../../state/use-atom-command";import { useAtomQueryRunner } from "../../state/use-atom-query-runner";import { useRemoteConnectionStatus, useRemoteEnvironmentRuntime, useSavedRemoteConnections,} from "../../state/use-remote-environment-registry";import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; interface EnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null;} const environmentOptionOrder = Order.mapInput( Order.Struct({ label: Order.String, }), (environment: EnvironmentOption) => ({ label: environment.label }),); function platformFromOs(os: string | null | undefined): string { if (os === "windows") return "Win32"; if (os === "darwin") return "MacIntel"; if (os === "linux") return "Linux"; return "";} function errorMessage(error: unknown): string { return error instanceof Error && error.message.trim().length > 0 ? error.message : "An error occurred.";} function stringParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) return value[0] ?? null; return value ?? null;} function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource { const source = stringParam(value); if ( source === "url" || source === "github" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" ) { return source; } return "url";} function SectionTitle(props: { readonly children: string }) { return ( <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"> {props.children} </Text> );} function AddProjectShell(props: { readonly children: ReactNode }) { const insets = useSafeAreaInsets(); return ( // collapsable={false} is load-bearing: if this wrapper is flattened, the // ScrollView lands directly under RNSSafeAreaView and RNS's formSheet // scroll-view frame correction mistakes this full-height wrapper for a // "header" sibling, coercing the ScrollView to zero height (blank sheet // as soon as the sheet re-lays-out, e.g. when the keyboard opens). <View collapsable={false} className="flex-1 bg-sheet"> <ScrollView keyboardShouldPersistTaps="handled" contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 16, paddingBottom: Math.max(insets.bottom, 18) + 18, gap: 10, }} > {props.children} </ScrollView> </View> );} function ListSection(props: { readonly children: ReactNode }) { return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>;} function ListRow(props: { readonly title: string; readonly subtitle?: string | null; readonly icon: ReactNode; readonly disabled?: boolean; readonly selected?: boolean; readonly isFirst?: boolean; readonly right?: ReactNode; readonly onPress?: () => void;}) { return ( <Pressable disabled={props.disabled} onPress={props.onPress} className={cn( "bg-card px-3.5 py-2.5 active:opacity-70", !props.isFirst && "border-t border-border-subtle", props.disabled && "opacity-[0.45]", )} > <View className="flex-row items-center gap-3"> <View className={ props.selected ? "h-7 w-7 items-center justify-center rounded-full bg-primary" : "h-7 w-7 items-center justify-center" } > {props.icon} </View> <View className="flex-1 gap-0.5"> <Text className="text-base leading-snug font-t3-bold">{props.title}</Text> {props.subtitle ? ( <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}> {props.subtitle} </Text> ) : null} </View> {"right" in props ? ( props.right ) : !props.disabled ? ( <SymbolView name="chevron.right" size={13} tintColorClassName={"accent-chevron"} type="monochrome" /> ) : null} </View> </Pressable> );} function PrimaryActionButton(props: { readonly label: string; readonly disabled?: boolean; readonly loading?: boolean; readonly onPress: () => void;}) { return ( <Pressable disabled={props.disabled} onPress={props.onPress} className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45" > {props.loading ? ( <ActivityIndicator colorClassName={String("accent-primary-foreground")} /> ) : ( <Text className="text-base font-t3-bold text-primary-foreground">{props.label}</Text> )} </Pressable> );} function ProjectPathInput(props: { readonly value: string; readonly onChangeText: (value: string) => void; readonly onSubmit: () => void;}) { return ( <TextInput className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" value={props.value} onChangeText={props.onChangeText} autoCapitalize="none" autoCorrect={false} placeholder="~/projects/my-app" returnKeyType="done" onSubmitEditing={props.onSubmit} /> );} // `pinnedDirectoryName` is the repository folder the clone destination keeps// appended to whatever folder the user browses to. The plain add-project flow// passes nothing, so it keeps proposing the browsed folder itself.function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirectoryName = "") { const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? ""); const [pathInput, commitPathInput] = useState(() => getCloneDestinationPath( getAddProjectInitialQuery(environmentBaseDirectory), pinnedDirectoryName, ), ); const previousEnvironmentIdRef = useRef(environmentId); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, }); const [browseNavigation] = useState(createBrowseNavigationCoordinator); const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); const setPathInput = useCallback( (path: string) => { browseNavigation.invalidate(); setIsBrowseNavigating(false); commitPathInput(path); }, [browseNavigation], ); const navigateToBrowsePath = useCallback( async (input: { readonly browseDirectoryPath: string; readonly selectedDirectoryName?: string; }) => { const selectedDirectoryPath = input.selectedDirectoryName ? appendBrowsePathSegment(input.browseDirectoryPath, input.selectedDirectoryName) : input.browseDirectoryPath; const nextPathInput = pinnedDirectoryName && input.selectedDirectoryName ? getCloneDestinationBrowsePath({ browseDirectoryPath: input.browseDirectoryPath, selectedDirectoryName: input.selectedDirectoryName, cloneDirectoryName: pinnedDirectoryName, caseSensitive: clonePathCaseSensitive, }) : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName); setIsBrowseNavigating(true); const committed = await browseNavigation.run( async () => { if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, input: { partialPath: selectedDirectoryPath }, }); } }, () => commitPathInput(nextPathInput), ); if (committed) { setIsBrowseNavigating(false); } return committed; }, [ browseNavigation, clonePathCaseSensitive, environment, environmentRuntime?.connectionState, loadBrowsePath, pinnedDirectoryName, ], ); useEffect(() => { if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { previousEnvironmentIdRef.current = environmentId; setPathInput( getCloneDestinationPath( getAddProjectInitialQuery(environmentBaseDirectory), pinnedDirectoryName, ), ); } }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]); useEffect( () => () => { browseNavigation.invalidate(); }, [browseNavigation], ); return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath };} function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); return useMemo<ReadonlyArray<EnvironmentOption>>(() => { const runtimeByEnvironmentId = new Map( connectedEnvironments.map((environment) => [environment.environmentId, environment] as const), ); const options = Object.values(savedConnectionsById).map((connection) => { const config = serverConfigByEnvironmentId.get(connection.environmentId); const runtime = runtimeByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, }; }); return Arr.sort(options, environmentOptionOrder); }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]);} function useSelectedEnvironment(): { readonly environmentOptions: ReadonlyArray<EnvironmentOption>; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;} { const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); const environmentOptions = useEnvironmentOptions(); const selectedEnvironment = environmentOptions.find( (environment) => environment.environmentId === selectedEnvironmentId && canCreateProjectInEnvironment(environment.connectionState), ) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? null; return { environmentOptions, selectedEnvironment, setSelectedEnvironmentId, };} function EmptyEnvironmentState() { const navigation = useNavigation(); return ( <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> <Text className="text-center text-lg font-t3-bold">Environment unavailable</Text> <Text className="text-center text-sm leading-normal text-foreground-muted"> Start or reconnect an environment before adding a project. </Text> <Pressable onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text> </Pressable> </View> );} function SourceControlRow(props: { readonly source: AddProjectRemoteSource; readonly selectedEnvironmentId: EnvironmentId; readonly ready: boolean; readonly hint: string; readonly isFirst: boolean;}) { const navigation = useNavigation(); const title = props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; const subtitle = props.source === "url" ? "Clone from a remote URL" : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; const icon = props.source === "url" ? ( <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" /> ) : ( <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" /> ); if (!props.ready) { return ( <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} /> ); } return ( <ListRow title={title} subtitle={subtitle} icon={icon} isFirst={props.isFirst} onPress={() => navigation.dispatch( StackActions.push("AddProjectRepository", { environmentId: props.selectedEnvironmentId, source: props.source, }), ) } /> );} export function AddProjectSourceScreen() { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, input: {}, }), ); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); return ( <AddProjectShell> {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} {environmentOptions.length > 1 ? ( <> <SectionTitle>Environments</SectionTitle> <ListSection> {environmentOptions.map((environment, index) => ( <ListRow key={environment.environmentId} title={environment.label} subtitle={ canCreateProjectInEnvironment(environment.connectionState) ? environment.environmentId : connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, traceId: environment.connectionErrorTraceId, }) }Lines 1–500 of 1003. Load more
import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, } from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, type EnvironmentMachineKind, ProjectId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useRemoteConnectionStatus, useRemoteEnvironmentRuntime, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; interface EnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; } const environmentOptionOrder = Order.mapInput( Order.Struct({ label: Order.String, }), (environment: EnvironmentOption) => ({ label: environment.label }), ); function platformFromOs(os: string | null | undefined): string { if (os === "windows") return "Win32"; if (os === "darwin") return "MacIntel"; if (os === "linux") return "Linux"; return ""; } function errorMessage(error: unknown): string { return error instanceof Error && error.message.trim().length > 0 ? error.message : "An error occurred."; } function stringParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) return value[0] ?? null; return value ?? null; } function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource { const source = stringParam(value); if ( source === "url" || source === "github" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" ) { return source; } return "url"; } function SectionTitle(props: { readonly children: string }) { return ( <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"> {props.children} </Text> ); } function AddProjectShell(props: { readonly children: ReactNode }) { const insets = useSafeAreaInsets(); return ( // collapsable={false} is load-bearing: if this wrapper is flattened, the // ScrollView lands directly under RNSSafeAreaView and RNS's formSheet // scroll-view frame correction mistakes this full-height wrapper for a // "header" sibling, coercing the ScrollView to zero height (blank sheet // as soon as the sheet re-lays-out, e.g. when the keyboard opens). <View collapsable={false} className="flex-1 bg-sheet"> <ScrollView keyboardShouldPersistTaps="handled" contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 16, paddingBottom: Math.max(insets.bottom, 18) + 18, gap: 10, }} > {props.children} </ScrollView> </View> ); } function ListSection(props: { readonly children: ReactNode }) { return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>; } function ListRow(props: { readonly title: string; readonly subtitle?: string | null; readonly icon: ReactNode; readonly disabled?: boolean; readonly selected?: boolean; readonly isFirst?: boolean; readonly right?: ReactNode; readonly onPress?: () => void; }) { return ( <Pressable disabled={props.disabled} onPress={props.onPress} className={cn( "bg-card px-3.5 py-2.5 active:opacity-70", !props.isFirst && "border-t border-border-subtle", props.disabled && "opacity-[0.45]", )} > <View className="flex-row items-center gap-3"> <View className={ props.selected ? "h-7 w-7 items-center justify-center rounded-full bg-primary" : "h-7 w-7 items-center justify-center" } > {props.icon} </View> <View className="flex-1 gap-0.5"> <Text className="text-base leading-snug font-t3-bold">{props.title}</Text> {props.subtitle ? ( <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}> {props.subtitle} </Text> ) : null} </View> {"right" in props ? ( props.right ) : !props.disabled ? ( <SymbolView name="chevron.right" size={13} tintColorClassName={"accent-chevron"} type="monochrome" /> ) : null} </View> </Pressable> ); } function PrimaryActionButton(props: { readonly label: string; readonly disabled?: boolean; readonly loading?: boolean; readonly onPress: () => void; }) { return ( <Pressable disabled={props.disabled} onPress={props.onPress} className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45" > {props.loading ? ( <ActivityIndicator colorClassName={String("accent-primary-foreground")} /> ) : ( <Text className="text-base font-t3-bold text-primary-foreground">{props.label}</Text> )} </Pressable> ); } function ProjectPathInput(props: { readonly value: string; readonly onChangeText: (value: string) => void; readonly onSubmit: () => void; }) { return ( <TextInput className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug" value={props.value} onChangeText={props.onChangeText} autoCapitalize="none" autoCorrect={false} placeholder="~/projects/my-app" returnKeyType="done" onSubmitEditing={props.onSubmit} /> ); } // `pinnedDirectoryName` is the repository folder the clone destination keeps // appended to whatever folder the user browses to. The plain add-project flow // passes nothing, so it keeps proposing the browsed folder itself. function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirectoryName = "") { const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? ""); const [pathInput, commitPathInput] = useState(() => getCloneDestinationPath( getAddProjectInitialQuery(environmentBaseDirectory), pinnedDirectoryName, ), ); const previousEnvironmentIdRef = useRef(environmentId); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { reportFailure: false, reportDefect: false, }); const [browseNavigation] = useState(createBrowseNavigationCoordinator); const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); const setPathInput = useCallback( (path: string) => { browseNavigation.invalidate(); setIsBrowseNavigating(false); commitPathInput(path); }, [browseNavigation], ); const navigateToBrowsePath = useCallback( async (input: { readonly browseDirectoryPath: string; readonly selectedDirectoryName?: string; }) => { const selectedDirectoryPath = input.selectedDirectoryName ? appendBrowsePathSegment(input.browseDirectoryPath, input.selectedDirectoryName) : input.browseDirectoryPath; const nextPathInput = pinnedDirectoryName && input.selectedDirectoryName ? getCloneDestinationBrowsePath({ browseDirectoryPath: input.browseDirectoryPath, selectedDirectoryName: input.selectedDirectoryName, cloneDirectoryName: pinnedDirectoryName, caseSensitive: clonePathCaseSensitive, }) : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName); setIsBrowseNavigating(true); const committed = await browseNavigation.run( async () => { if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, input: { partialPath: selectedDirectoryPath }, }); } }, () => commitPathInput(nextPathInput), ); if (committed) { setIsBrowseNavigating(false); } return committed; }, [ browseNavigation, clonePathCaseSensitive, environment, environmentRuntime?.connectionState, loadBrowsePath, pinnedDirectoryName, ], ); useEffect(() => { if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { previousEnvironmentIdRef.current = environmentId; setPathInput( getCloneDestinationPath( getAddProjectInitialQuery(environmentBaseDirectory), pinnedDirectoryName, ), ); } }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]); useEffect( () => () => { browseNavigation.invalidate(); }, [browseNavigation], ); return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); return useMemo<ReadonlyArray<EnvironmentOption>>(() => { const runtimeByEnvironmentId = new Map( connectedEnvironments.map((environment) => [environment.environmentId, environment] as const), ); const options = Object.values(savedConnectionsById).map((connection) => { const config = serverConfigByEnvironmentId.get(connection.environmentId); const runtime = runtimeByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, }; }); return Arr.sort(options, environmentOptionOrder); }, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]); } function useSelectedEnvironment(): { readonly environmentOptions: ReadonlyArray<EnvironmentOption>; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); const environmentOptions = useEnvironmentOptions(); const selectedEnvironment = environmentOptions.find( (environment) => environment.environmentId === selectedEnvironmentId && canCreateProjectInEnvironment(environment.connectionState), ) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? null; return { environmentOptions, selectedEnvironment, setSelectedEnvironmentId, }; } function EmptyEnvironmentState() { const navigation = useNavigation(); return ( <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> <Text className="text-center text-lg font-t3-bold">Environment unavailable</Text> <Text className="text-center text-sm leading-normal text-foreground-muted"> Start or reconnect an environment before adding a project. </Text> <Pressable onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text> </Pressable> </View> ); } function SourceControlRow(props: { readonly source: AddProjectRemoteSource; readonly selectedEnvironmentId: EnvironmentId; readonly ready: boolean; readonly hint: string; readonly isFirst: boolean; }) { const navigation = useNavigation(); const title = props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; const subtitle = props.source === "url" ? "Clone from a remote URL" : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; const icon = props.source === "url" ? ( <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" /> ) : ( <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" /> ); if (!props.ready) { return ( <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} /> ); } return ( <ListRow title={title} subtitle={subtitle} icon={icon} isFirst={props.isFirst} onPress={() => navigation.dispatch( StackActions.push("AddProjectRepository", { environmentId: props.selectedEnvironmentId, source: props.source, }), ) } /> ); } export function AddProjectSourceScreen() { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, input: {}, }), ); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); return ( <AddProjectShell> {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} {environmentOptions.length > 1 ? ( <> <SectionTitle>Environments</SectionTitle> <ListSection> {environmentOptions.map((environment, index) => ( <ListRow key={environment.environmentId} title={environment.label} subtitle={ canCreateProjectInEnvironment(environment.connectionState) ? environment.environmentId : connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, traceId: environment.connectionErrorTraceId, }) }