import {
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
addProjectRemoteSourceProvider,
buildAddProjectRemoteSourceReadiness,
buildProjectCreateCommand,
findExistingAddProject,
getAddProjectInitialQuery,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
import {
appendBrowsePathSegment,
canNavigateUp,
ensureBrowseDirectoryPath,
getBrowseDirectoryPath,
getBrowseLeafPathSegment,
getBrowseParentPath,
hasTrailingPathSeparator,
inferProjectTitleFromPath,
isFilesystemBrowseQuery,
} 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, 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 { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
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 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 useEnvironmentOptions(): ReadonlyArray {
const serverConfigByEnvironmentId = useServerConfigs();
const { savedConnectionsById } = useSavedRemoteConnections();
return useMemo>(() => {
const options = Object.values(savedConnectionsById).map((connection) => {
const config = serverConfigByEnvironmentId.get(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 [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null);
const environmentOptions = useEnvironmentOptions();
const selectedEnvironment =
environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
environmentOptions[0] ??
null;
return {
environmentOptions,
selectedEnvironment,
setSelectedEnvironmentId,
};
}
function EmptyEnvironmentState() {
const navigation = useNavigation();
return (
No environments connected
Add 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 (
{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={() =>
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) 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 (
environmentOptions.find((environment) => environment.environmentId === environmentId) ??
environmentOptions[0] ??
null
);
}
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}
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 = useEnvironmentQuery(
browseInput === null
? null
: filesystemEnvironment.browse({
environmentId: props.environment.environmentId,
input: 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(props: { readonly environmentId?: string | string[] }) {
const environment = useEnvironmentFromParam(props.environmentId);
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);
const result = await createProject(resolved.path);
if (result && AsyncResult.isFailure(result)) {
setError(errorMessage(Cause.squash(result.cause)));
}
setIsSubmitting(false);
}, [createProject, environment, 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 [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);
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, isSubmitting, pathInput, remoteUrl]);
return (
{error ? : null}
{repositoryTitle ? (
{repositoryTitle}
{remoteUrl}
) : null}
{environment ? (
<>
void submitPath()}
/>
void submitPath()}
loading={isSubmitting}
/>
>
) : (
)}
);
}