import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, 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, ensureBrowseDirectoryPath, inferProjectTitleFromPath, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { 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 { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { useThemeColor } from "../../lib/useThemeColor"; 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 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 ( {props.children} ); } 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). {props.children} ); } function ListSection(props: { readonly children: ReactNode }) { return {props.children}; } 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; }) { const chevronColor = useThemeColor("--color-chevron"); return ( {props.icon} {props.title} {props.subtitle ? ( {props.subtitle} ) : null} {"right" in props ? ( props.right ) : !props.disabled ? ( ) : null} ); } function PrimaryActionButton(props: { readonly label: string; readonly disabled?: boolean; readonly loading?: boolean; readonly onPress: () => void; }) { const primaryForeground = useThemeColor("--color-primary-foreground"); return ( {props.loading ? ( ) : ( {props.label} )} ); } function ProjectPathInput(props: { readonly value: string; readonly onChangeText: (value: string) => void; readonly onSubmit: () => void; }) { return ( ); } function useBrowsePathInput(environment: EnvironmentOption | null) { const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; const [pathInput, commitPathInput] = useState(() => getAddProjectInitialQuery(environmentBaseDirectory), ); 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 (path: string) => { setIsBrowseNavigating(true); const committed = await browseNavigation.run( async () => { if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, input: { partialPath: path }, }); } }, () => commitPathInput(path), ); if (committed) { setIsBrowseNavigating(false); } return committed; }, [browseNavigation, environment, environmentRuntime?.connectionState, loadBrowsePath], ); useEffect(() => { if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { previousEnvironmentIdRef.current = environmentId; setPathInput(getAddProjectInitialQuery(environmentBaseDirectory)); } }, [environmentBaseDirectory, environmentId, setPathInput]); useEffect( () => () => { browseNavigation.invalidate(); }, [browseNavigation], ); return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } function useEnvironmentOptions(): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); return useMemo>(() => { 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), 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; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(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 ( Environment unavailable Start or reconnect an environment before adding a project. navigation.dispatch(StackActions.replace("ConnectionsNew"))} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > Add environment ); } function SourceControlRow(props: { readonly source: AddProjectRemoteSource; readonly selectedEnvironmentId: EnvironmentId; readonly ready: boolean; readonly hint: string; readonly isFirst: boolean; }) { const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); 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" ? ( ) : ( ); if (!props.ready) { return ( ); } return ( navigation.navigate("NewTaskSheet", { screen: "AddProjectRepository", params: { environmentId: props.selectedEnvironmentId, source: props.source, }, }) } /> ); } export function AddProjectSourceScreen() { const navigation = useNavigation(); const accentColor = useThemeColor("--color-icon-muted"); const iconColor = useThemeColor("--color-icon"); 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 ( {selectedEnvironment === null ? : null} {environmentOptions.length > 1 ? ( <> Environments {environmentOptions.map((environment, index) => ( } selected={environment.environmentId === selectedEnvironment?.environmentId} disabled={!canCreateProjectInEnvironment(environment.connectionState)} isFirst={index === 0} right={ environment.environmentId === selectedEnvironment?.environmentId ? ( ) : null } onPress={() => setSelectedEnvironmentId(environment.environmentId)} /> ))} ) : null} {selectedEnvironment ? ( <> } isFirst onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProjectLocal", params: { environmentId: selectedEnvironment.environmentId, }, }) } /> {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( (candidate) => ( ), )} {discoveryState.isPending ? : null} ) : null} ); } function useCreateProject(environment: EnvironmentOption | null) { const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); return useCallback( async (workspaceRoot: string) => { if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; const existing = findExistingAddProject({ projects, environmentId: environment.environmentId, path: workspaceRoot, }); if (existing) { Alert.alert("Project already exists", existing.title); navigation.dispatch( StackActions.replace("NewTaskDraft", { environmentId: existing.environmentId, projectId: existing.id, title: existing.title, }), ); return; } const projectId = ProjectId.make(uuidv4()); const command = buildProjectCreateCommand({ commandId: CommandId.make(uuidv4()), projectId, workspaceRoot, createdAt: new Date().toISOString(), }); const result = await createProject({ environmentId: environment.environmentId, input: command, }); if (AsyncResult.isFailure(result)) { return result; } navigation.dispatch( StackActions.replace("NewTaskDraft", { environmentId: environment.environmentId, projectId, title: inferProjectTitleFromPath(workspaceRoot), }), ); return result; }, [createProject, environment, projects, navigation], ); } function useEnvironmentFromParam( environmentIdParam: string | string[] | undefined, ): EnvironmentOption | null { const environmentOptions = useEnvironmentOptions(); const environmentId = stringParam(environmentIdParam) as EnvironmentId | null; return resolveAddProjectEnvironment(environmentOptions, environmentId); } export function AddProjectRepositoryScreen(props: { readonly environmentId?: string | string[]; readonly source?: string | string[]; }) { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const source = sourceFromParam(props.source); const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const lookupRepository = useCallback(async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; setError(null); setIsSubmitting(true); const provider = addProjectRemoteSourceProvider(source); if (!provider) { const remoteUrl = repositoryInput.trim(); navigation.navigate("NewTaskSheet", { screen: "AddProjectDestination", params: { environmentId: environment.environmentId, source, remoteUrl, repositoryTitle: remoteUrl, }, }); setIsSubmitting(false); return; } const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { provider, repository: repositoryInput.trim(), }, }); if (AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } else { const repository = result.value; navigation.navigate("NewTaskSheet", { screen: "AddProjectDestination", params: { environmentId: environment.environmentId, source, remoteUrl: repository.sshUrl, repositoryTitle: repository.nameWithOwner, }, }); } setIsSubmitting(false); }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( {error ? : null} {environment ? ( <> void lookupRepository()} /> void lookupRepository()} loading={isSubmitting} /> ) : ( )} ); } function FolderBrowser(props: { readonly environment: EnvironmentOption; readonly pathInput: string; readonly setPathInput: (path: string) => void; readonly navigateToBrowsePath: (path: string) => Promise; }) { const accentColor = useThemeColor("--color-icon-muted"); const browsePath = useMemo( () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], ); const browseInput = useMemo( () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); const browseState = useEnvironmentQuery( browseInput === null ? null : filesystemEnvironment.browse({ environmentId: props.environment.environmentId, input: browseInput, }), ); const { visibleEntries: visibleBrowseEntries } = useMemo( () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery), [browsePath.filterQuery, browseState.data?.entries], ); return ( <> Browse folders {browseState.error ? : null} {browseState.isPending && browseState.data === null ? ( ) : null} {browsePath.canBrowseUp ? ( } isFirst right={null} onPress={() => { if (browsePath.parentPath) { void props.navigateToBrowsePath(browsePath.parentPath); } }} /> ) : null} {visibleBrowseEntries.map((entry, index) => ( } isFirst={index === 0 && !browsePath.canBrowseUp} right={null} onPress={() => { const nextPath = browsePath.directoryPath.length > 0 ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) : ensureBrowseDirectoryPath(entry.fullPath); void props.navigateToBrowsePath(nextPath); }} /> ))} ); } export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const submitPath = useCallback(async () => { if (!environment || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, currentProjectCwd: null, platform: environment.platform, }); if (!resolved.ok) { setError(resolved.error); return; } setIsSubmitting(true); const result = await createProject(resolved.path); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } setIsSubmitting(false); }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( {error ? : null} {environment ? ( <> void submitPath()} /> void submitPath()} loading={isSubmitting} /> ) : ( )} ); } export function AddProjectDestinationScreen(props: { readonly environmentId?: string | string[]; readonly remoteUrl?: string | string[]; readonly repositoryTitle?: string | string[]; }) { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const submitPath = useCallback(async () => { if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, currentProjectCwd: null, platform: environment.platform, }); if (!resolved.ok) { setError(resolved.error); return; } setIsSubmitting(true); const cloneResult = await cloneRepository({ environmentId: environment.environmentId, input: { remoteUrl, destinationPath: resolved.path, }, }); if (AsyncResult.isFailure(cloneResult)) { setError(errorMessage(Cause.squash(cloneResult.cause))); } else { const createResult = await createProject(cloneResult.value.cwd); if (createResult && AsyncResult.isFailure(createResult)) { setError(errorMessage(Cause.squash(createResult.cause))); } } setIsSubmitting(false); }, [ cloneRepository, createProject, environment, isBrowseNavigating, isSubmitting, pathInput, remoteUrl, ]); return ( {error ? : null} {repositoryTitle ? ( {repositoryTitle} {remoteUrl} ) : null} {environment ? ( <> void submitPath()} /> void submitPath()} loading={isSubmitting} /> ) : ( )} ); }