import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, appendBrowsePathSegment, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, canNavigateUp, ensureBrowseDirectoryPath, findExistingAddProject, getAddProjectInitialQuery, getBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isFilesystemBrowseQuery, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo, 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 Order from "effect/Order"; 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 { getEnvironmentClient } from "../../state/environment-session-registry"; import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; import { refreshSourceControlDiscoveryForEnvironment, useSourceControlDiscovery, } from "../../state/use-source-control-discovery"; import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader"; interface EnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; readonly platform: string; readonly baseDirectory: string | null; } const environmentOptionOrder = Order.mapInput( Order.Struct({ label: Order.String, }), (environment: EnvironmentOption) => ({ label: environment.label }), ); const browseEntryOrder = Order.mapInput( Order.String, (entry: { readonly name: string }) => entry.name, ); 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 title: string; readonly closeToNew?: boolean; readonly children: ReactNode; }) { const router = useRouter(); const insets = useSafeAreaInsets(); return ( router.back(), }} /> {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 borderColor = useThemeColor("--color-border-subtle"); 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 useEnvironmentOptions(): ReadonlyArray { const { serverConfigByEnvironmentId } = useRemoteCatalog(); const { savedConnectionsById } = useRemoteEnvironmentState(); return useMemo>(() => { const options = Object.values(savedConnectionsById).map((connection) => { const config = serverConfigByEnvironmentId[connection.environmentId]; return { environmentId: connection.environmentId, label: connection.environmentLabel, platform: platformFromOs(config?.environment.platform.os ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, }; }); return Arr.sort(options, environmentOptionOrder); }, [savedConnectionsById, serverConfigByEnvironmentId]); } function useSelectedEnvironment(): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string }>(); const environmentOptions = useEnvironmentOptions(); const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null; const selectedEnvironment = environmentOptions.find( (environment) => environment.environmentId === requestedEnvironmentId, ) ?? environmentOptions[0] ?? null; return { environmentOptions, selectedEnvironment, setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }), }; } function EmptyEnvironmentState() { const router = useRouter(); return ( No environments connected Add an environment before adding a project. router.replace("/connections/new")} 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 router = useRouter(); 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 ( router.push({ pathname: "/new/add-project/repository", params: { environmentId: props.selectedEnvironmentId, source: props.source, }, }) } /> ); } export function AddProjectSourceScreen() { const router = useRouter(); const accentColor = useThemeColor("--color-icon-muted"); const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); useEffect(() => { if (!selectedEnvironment) return; void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); }, [selectedEnvironment]); return ( {environmentOptions.length === 0 ? : null} {environmentOptions.length > 1 ? ( <> Connected environments {environmentOptions.map((environment, index) => ( } selected={environment.environmentId === selectedEnvironment?.environmentId} isFirst={index === 0} right={ environment.environmentId === selectedEnvironment?.environmentId ? ( ) : null } onPress={() => setSelectedEnvironmentId(environment.environmentId)} /> ))} ) : null} {selectedEnvironment ? ( <> } isFirst onPress={() => router.push({ pathname: "/new/add-project/local", params: { environmentId: selectedEnvironment.environmentId }, }) } /> {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( (candidate) => ( ), )} {discoveryState.isPending ? : null} ) : null} ); } function useCreateProject(environment: EnvironmentOption | null) { const router = useRouter(); const { projects } = useRemoteCatalog(); return useCallback( async (workspaceRoot: string) => { if (!environment) return; const client = getEnvironmentClient(environment.environmentId); if (!client) throw new Error("Environment API is not available."); const existing = findExistingAddProject({ projects, environmentId: environment.environmentId, path: workspaceRoot, }); if (existing) { Alert.alert("Project already exists", existing.title); router.replace({ pathname: "/new/draft", params: { environmentId: existing.environmentId, projectId: existing.id, title: existing.title, }, }); return; } const projectId = ProjectId.make(uuidv4()); await client.orchestration.dispatchCommand( buildProjectCreateCommand({ commandId: CommandId.make(uuidv4()), projectId, workspaceRoot, createdAt: new Date().toISOString(), }), ); router.replace({ pathname: "/new/draft", params: { environmentId: environment.environmentId, projectId, title: inferProjectTitleFromPath(workspaceRoot), }, }); }, [environment, projects, router], ); } function useEnvironmentFromParam(): EnvironmentOption | null { const params = useLocalSearchParams<{ environmentId?: string }>(); const environmentOptions = useEnvironmentOptions(); const environmentId = stringParam(params.environmentId) as EnvironmentId | null; return ( environmentOptions.find((environment) => environment.environmentId === environmentId) ?? environmentOptions[0] ?? null ); } export function AddProjectRepositoryScreen() { const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); const environment = useEnvironmentFromParam(); const source = sourceFromParam(params.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); try { const provider = addProjectRemoteSourceProvider(source); if (!provider) { const remoteUrl = repositoryInput.trim(); router.push({ pathname: "/new/add-project/destination", params: { environmentId: environment.environmentId, source, remoteUrl, repositoryTitle: remoteUrl, }, }); return; } const client = getEnvironmentClient(environment.environmentId); if (!client) throw new Error("Environment API is not available."); const repository = await client.sourceControl.lookupRepository({ provider, repository: repositoryInput.trim(), }); router.push({ pathname: "/new/add-project/destination", params: { environmentId: environment.environmentId, source, remoteUrl: repository.sshUrl, repositoryTitle: repository.nameWithOwner, }, }); } catch (nextError) { setError(errorMessage(nextError)); } finally { setIsSubmitting(false); } }, [environment, isSubmitting, repositoryInput, router, source]); return ( {error ? : null} void lookupRepository()} /> void lookupRepository()} loading={isSubmitting} /> ); } function FolderBrowser(props: { readonly environment: EnvironmentOption; readonly pathInput: string; readonly setPathInput: (path: string) => void; }) { const accentColor = useThemeColor("--color-icon-muted"); const browseDirectoryPath = useMemo( () => isFilesystemBrowseQuery(props.pathInput, props.environment.platform) ? getBrowseDirectoryPath(props.pathInput) : "", [props.environment.platform, props.pathInput], ); const browseFilterQuery = browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() : ""; const browseInput = useMemo( () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), [browseDirectoryPath], ); const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); const visibleBrowseEntries = useMemo( () => Arr.sort( Arr.filter( browseState.data?.entries ?? [], (entry) => !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), ), browseEntryOrder, ), [browseFilterQuery, browseState.data?.entries], ); const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); const canBrowseUpPath = canNavigateUp(browseDirectoryPath); return ( <> Browse folders {browseState.error ? : null} {browseState.isPending && browseState.data === null ? ( ) : null} {canBrowseUpPath ? ( } isFirst right={null} onPress={() => { if (parentBrowsePath) props.setPathInput(parentBrowsePath); }} /> ) : null} {visibleBrowseEntries.map((entry, index) => ( } isFirst={index === 0 && !canBrowseUpPath} right={null} onPress={() => props.setPathInput( browseDirectoryPath.length > 0 ? appendBrowsePathSegment(browseDirectoryPath, entry.name) : ensureBrowseDirectoryPath(entry.fullPath), ) } /> ))} ); } export function AddProjectLocalFolderScreen() { const environment = useEnvironmentFromParam(); const createProject = useCreateProject(environment); const [pathInput, setPathInput] = useState(() => getAddProjectInitialQuery(environment?.baseDirectory), ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!environment) return; setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); }, [environment]); const submitPath = useCallback(async () => { if (!environment || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, currentProjectCwd: null, platform: environment.platform, }); if (!resolved.ok) { setError(resolved.error); return; } setIsSubmitting(true); try { await createProject(resolved.path); } catch (nextError) { setError(errorMessage(nextError)); } finally { setIsSubmitting(false); } }, [createProject, environment, isSubmitting, pathInput]); return ( {error ? : null} {environment ? ( <> void submitPath()} /> void submitPath()} loading={isSubmitting} /> ) : ( )} ); } export function AddProjectDestinationScreen() { const params = useLocalSearchParams<{ environmentId?: string; remoteUrl?: string; repositoryTitle?: string; }>(); const environment = useEnvironmentFromParam(); const createProject = useCreateProject(environment); const remoteUrl = stringParam(params.remoteUrl); const repositoryTitle = stringParam(params.repositoryTitle); const [pathInput, setPathInput] = useState(() => getAddProjectInitialQuery(environment?.baseDirectory), ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!environment) return; setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); }, [environment]); const submitPath = useCallback(async () => { if (!environment || !remoteUrl || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, currentProjectCwd: null, platform: environment.platform, }); if (!resolved.ok) { setError(resolved.error); return; } setIsSubmitting(true); try { const client = getEnvironmentClient(environment.environmentId); if (!client) throw new Error("Environment API is not available."); const result = await client.sourceControl.cloneRepository({ remoteUrl, destinationPath: resolved.path, }); await createProject(result.cwd); } catch (nextError) { setError(errorMessage(nextError)); } finally { setIsSubmitting(false); } }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); return ( {error ? : null} {repositoryTitle ? ( {repositoryTitle} {remoteUrl} ) : null} {environment ? ( <> void submitPath()} /> void submitPath()} loading={isSubmitting} /> ) : ( )} ); }