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 (
{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;
}) {
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;
}) {
return (
{props.loading ? (
) : (
{props.label}
)}
);
}
function ProjectPathInput(props: {
readonly value: string;
readonly onChangeText: (value: string) => void;
readonly onSubmit: () => void;
}) {
return (
);
}
// `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 {
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),
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;
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 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.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 (
{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.dispatch(
StackActions.push("AddProjectLocal", {
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(
CommonActions.reset({
index: 0,
routes: [
{
name: "NewTaskDraft",
params: {
environmentId: existing.environmentId,
projectId: existing.id,
title: existing.title,
},
},
],
}),
);
return;
}
const projectId = ProjectId.make(uuidv4());
const command = buildProjectCreateCommand({
commandId: CommandId.make(uuidv4()),
projectId,
workspaceRoot,
});
const result = await createProject({
environmentId: environment.environmentId,
input: command,
});
if (AsyncResult.isFailure(result)) {
return result;
}
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: "NewTaskDraft",
params: {
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 = normalizePastedCloneUrl(repositoryInput);
navigation.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl,
repositoryTitle: remoteUrl,
repositoryName: getCloneDirectoryName(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.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl: getDefaultCloneUrl(repository),
repositoryTitle: repository.nameWithOwner,
repositoryName: getCloneDirectoryName(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: (input: {
readonly browseDirectoryPath: string;
readonly selectedDirectoryName?: string;
}) => Promise;
readonly pinnedDirectoryName?: string;
}) {
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,
}),
);
// A pinned repository folder does not exist yet, so filtering the listing by
// it would empty the folder picker. Anything the user typed still filters.
const pinnedDirectoryName = props.pinnedDirectoryName ?? "";
const pinnedDirectoryMatches = isWindowsPlatform(props.environment.platform)
? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase()
: browsePath.filterQuery === pinnedDirectoryName;
const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery;
const { visibleEntries: visibleBrowseEntries } = useMemo(
() => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery),
[browseFilterQuery, 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({
browseDirectoryPath: browsePath.parentPath,
});
}
}}
/>
) : null}
{visibleBrowseEntries.map((entry, index) => (
}
isFirst={index === 0 && !browsePath.canBrowseUp}
right={null}
onPress={() => {
void props.navigateToBrowsePath({
browseDirectoryPath: browsePath.directoryPath,
selectedDirectoryName: entry.name,
});
}}
/>
))}
>
);
}
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[];
readonly repositoryName?: 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);
// A lookup derives this from "owner/repo", a pasted clone URL from its own
// last segment. Older links without the param keep the browsed folder.
// Trim once here: the path input and the folder-list filter must compare the
// same value, or a deep link with a padded param empties the folder picker.
const repositoryName = stringParam(props.repositoryName)?.trim() ?? "";
const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(
environment,
repositoryName,
);
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}
/>
>
) : (
)}
);
}