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}
/>
>
) : (
)}
);
}