import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail";
import * as Dialog from "../../components/ui/Dialog";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { NavigationRow } from "../../components/ui/NavigationRow";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { Button } from "../../components/ui/Button";
import {
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
addProjectRemoteSourceProvider,
buildAddProjectRemoteSourceReadiness,
buildProjectCreateCommand,
canCreateProjectInEnvironment,
findExistingAddProject,
getAddProjectInitialQuery,
getCloneDestinationBrowsePath,
getCloneDestinationPath,
getCloneDirectoryName,
getDefaultCloneUrl,
getNewProjectPathPreview,
getNewProjectPublishTarget,
getNewProjectPublishTargets,
getNewProjectRepository,
defaultHostCloneUrl,
normalizePastedCloneUrl,
parseAddProjectRemoteSource,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
import {
connectionStatusText,
type EnvironmentConnectionPhase,
} from "@t3tools/client-runtime/connection";
import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
resolveFilesystemReadAccess,
} from "@t3tools/client-runtime/state/filesystem";
import {
appendBrowsePathSegment,
inferProjectTitleFromPath,
isWindowsPlatform,
} from "@t3tools/client-runtime/state/projects";
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
AuthFilesystemReadScope,
CommandId,
type EnvironmentId,
type EnvironmentMachineKind,
ProjectId,
resolveEnvironmentMachineKind,
SourceControlProviderKind,
} from "@t3tools/contracts";
import {
StackActions,
useNavigation,
type NavigationState,
type ParamListBase,
} from "@react-navigation/native";
import { SymbolView } from "../../components/ui/AppSymbol";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Platform, Pressable, 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/reactivity";
import { cn } from "../../lib/cn";
import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
import { useEnvironmentPresentation } from "../../state/presentation";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ErrorBanner } from "../../components/ui/ErrorBanner";
import { SourceControlIcon } from "./SourceControlIcon";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
import { uuidv4 } from "../../lib/uuid";
import { useAtomCommand } from "../../state/use-atom-command";
import { suggestAppAccessForRemote } from "./suggestAppAccess";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import {
useRemoteConnectionStatus,
useRemoteEnvironmentRuntime,
useSavedRemoteConnections,
} from "../../state/use-remote-environment-registry";
import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
import {
createNewTaskDraft,
setComposerDraftText,
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
interface EnvironmentOption {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly platform: string;
readonly machine: EnvironmentMachineKind;
readonly baseDirectory: string | null;
readonly newProjectsRoot: 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 {
return parseAddProjectRemoteSource(stringParam(value));
}
function SectionTitle(props: { readonly children: string }) {
return (
{props.children}
);
}
function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) {
const insets = useSafeAreaInsets();
return (
// A collapsible wrapper lets formSheet resizing mistake the full-height view for a header and collapse the ScrollView.
{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;
}) {
if (Platform.OS === "android") {
return (
);
}
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 ;
}
function ProjectPathInput(props: {
readonly value: string;
readonly onChangeText: (value: string) => void;
readonly onSubmit: () => void;
}) {
return (
);
}
function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirectoryName = "") {
const environmentId = environment?.environmentId ?? null;
const environmentBaseDirectory = environment?.baseDirectory ?? null;
const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? "");
const seedPath = getCloneDestinationPath(
getAddProjectInitialQuery(environmentBaseDirectory),
pinnedDirectoryName,
);
// Each host keeps the path last typed or browsed for it, and shows its own default until then,
// so switching hosts and back loses nothing. With no host selected (one dropping out briefly),
// the field keeps the last path typed anywhere.
const [paths, setPaths] = useState<{
readonly byHost: ReadonlyMap;
readonly last: string;
}>(() => ({ byHost: new Map(), last: seedPath }));
const pathInput =
environmentId === null ? paths.last : (paths.byHost.get(environmentId) ?? seedPath);
const commitPathInput = (path: string) =>
setPaths((current) => {
const byHost = new Map(current.byHost);
if (environmentId !== null) {
byHost.set(environmentId, path);
}
return { byHost, last: path };
});
const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
reportFailure: false,
reportDefect: false,
});
const [browseNavigation] = useState(createBrowseNavigationCoordinator);
const [navigatingEnvironmentId, setNavigatingEnvironmentId] = useState<
EnvironmentId | null | undefined
>(undefined);
const isBrowseNavigating = navigatingEnvironmentId === environmentId;
const setPathInput = (path: string) => {
browseNavigation.invalidate();
setNavigatingEnvironmentId(undefined);
commitPathInput(path);
};
const navigateToBrowsePath = 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);
setNavigatingEnvironmentId(environmentId);
const committed = await browseNavigation.run(
async () => {
if (
environment &&
readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) &&
canPreloadBrowsePath(environmentRuntime?.connectionState)
) {
await loadBrowsePath({
environmentId: environment.environmentId,
input: { partialPath: selectedDirectoryPath },
});
}
},
() => commitPathInput(nextPathInput),
);
if (committed) {
setNavigatingEnvironmentId(undefined);
}
return committed;
};
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates the browse navigation coordinator on unmount so in-flight browses do not commit
useEffect(
() => () => {
browseNavigation.invalidate();
},
[browseNavigation],
);
return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath };
}
function useEnvironmentOptions(
selectedEnvironmentId: EnvironmentId | null = null,
): ReadonlyArray {
const serverConfigByEnvironmentId = useServerConfigs();
const { savedConnectionsById } = useSavedRemoteConnections();
const { connectedEnvironments } = useRemoteConnectionStatus();
return ((): ReadonlyArray => {
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,
newProjectsRoot: config?.newProjectsRoot ?? null,
connectionState: runtime?.connectionState ?? "available",
connectionError: runtime?.connectionError ?? null,
connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
};
});
return Arr.sort(
options.filter(
(environment) =>
canCreateProjectInEnvironment(environment.connectionState) ||
environment.environmentId === selectedEnvironmentId,
),
environmentOptionOrder,
);
})();
}
function useSelectedEnvironment(requestedEnvironmentId: EnvironmentId | null): {
readonly environmentOptions: ReadonlyArray;
readonly selectedEnvironment: EnvironmentOption | null;
readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;
} {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(requestedEnvironmentId);
const [previousRequest, setPreviousRequest] = useState(requestedEnvironmentId);
if (previousRequest !== requestedEnvironmentId) {
setPreviousRequest(requestedEnvironmentId);
setSelectedEnvironmentId(requestedEnvironmentId);
}
const environmentOptions = useEnvironmentOptions(selectedEnvironmentId);
const selectedEnvironment =
environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ??
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.
);
}
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(props: { readonly environmentId?: string | string[] }) {
const navigation = useNavigation();
const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
useSelectedEnvironment(stringParam(props.environmentId) as EnvironmentId | null);
const canWriteSourceControl = useEnvironmentScope(
selectedEnvironment?.environmentId ?? null,
AuthSourceControlWriteScope,
);
const canCreateProject = useEnvironmentScope(
selectedEnvironment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const isConnected = canCreateProjectInEnvironment(selectedEnvironment?.connectionState);
const canCloneProject = isConnected && canWriteSourceControl && canCreateProject;
const discoveryState = useEnvironmentQuery(
selectedEnvironment === null || !isConnected
? null
: sourceControlEnvironment.discovery({
environmentId: selectedEnvironment.environmentId,
input: {},
}),
);
const readiness = buildAddProjectRemoteSourceReadiness(discoveryState.data);
return (
{selectedEnvironment === null ? : null}
{environmentOptions.length > 1 || (selectedEnvironment !== null && !isConnected) ? (
<>
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 ? (
<>
{selectedEnvironment.newProjectsRoot !== null ? (
}
isFirst
onPress={() =>
navigation.dispatch(
StackActions.push("AddProjectNew", {
environmentId: selectedEnvironment.environmentId,
}),
)
}
/>
) : null}
}
isFirst={selectedEnvironment.newProjectsRoot === null}
disabled={!isConnected || !canCreateProject}
onPress={() =>
navigation.dispatch(
StackActions.push("AddProjectLocal", {
environmentId: selectedEnvironment.environmentId,
}),
)
}
/>
{(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
(candidate) => (
),
)}
{discoveryState.isPending ? (
) : null}
>
) : null}
);
}
function getActiveRoute(navigation: {
getState: () => NavigationState | undefined;
}) {
const state = navigation.getState();
return state?.routes[state.index];
}
function useOpenNewTaskDraft() {
const navigation = useNavigation>();
const selectDetail = useSelectWorkspaceDetail();
return (
params: {
environmentId: EnvironmentId;
projectId: ProjectId;
title: string;
cloning?: "1";
draftId?: string;
},
originRoute: NavigationState["routes"][number] | undefined,
) => {
const activeRoute = getActiveRoute(navigation);
// A warm link can change the task without replacing its native route key.
if (
!originRoute ||
activeRoute?.key !== originRoute.key ||
activeRoute.name !== originRoute.name ||
activeRoute.params !== originRoute.params
)
return;
selectDetail({ name: "Thread", params: { draft: params } });
};
}
async function draftWithPrompt(
environmentId: EnvironmentId,
projectId: ProjectId,
prompt: string,
): Promise {
await waitForComposerDraftsLoaded();
const draftId = createNewTaskDraft({ environmentId, projectId });
setComposerDraftText(draftId, prompt);
return draftId;
}
function useCreateProject(environment: EnvironmentOption | null) {
const openNewTaskDraft = useOpenNewTaskDraft();
const navigation = useNavigation();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
const projects = useProjects();
return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => {
if (
!environment ||
!canCreateProjectInEnvironment(environment.connectionState) ||
!readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope)
) {
Dialog.alert(
"Project not added",
`This connection cannot add projects right now. Any existing files remain at ${workspaceRoot}.`,
);
return;
}
const existing = findExistingAddProject({
projects,
environmentId: environment.environmentId,
path: workspaceRoot,
});
if (existing) {
Dialog.alert("Project already exists", existing.title);
openNewTaskDraft(
{
environmentId: existing.environmentId,
projectId: existing.id,
title: existing.title,
},
originRoute,
);
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;
}
// A successful command can precede its project in the client store. The
// draft treats a missing project as a reason to return to the chooser.
const project = await waitForProject(
{ environmentId: environment.environmentId, projectId },
15_000,
);
if (project === null) {
Dialog.alert(
"Project added",
"The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
);
return result;
}
openNewTaskDraft(
{
environmentId: environment.environmentId,
projectId,
title: inferProjectTitleFromPath(workspaceRoot),
},
originRoute,
);
return result;
};
}
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 = 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);
};
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 = getFilesystemBrowsePath(props.pathInput, props.environment.platform);
const browseInput =
browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null;
const fileAccessSession = useEnvironmentQuery(
environmentSession.sessionStateAtom(props.environment.environmentId),
);
const fileEnvironment = useEnvironmentPresentation(props.environment.environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const browseState = useEnvironmentQuery(
!canReadFiles || browseInput === null
? null
: filesystemEnvironment.browse({
environmentId: props.environment.environmentId,
input: browseInput,
}),
);
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 } = filterFilesystemBrowseEntries(
browseState.data?.entries ?? [],
browseFilterQuery,
);
return (
<>
Browse folders
{!canReadFiles && !fileAccess.isPending ? (
) : null}
{browseState.error ? : null}
{fileAccess.isPending || (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 AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) {
const openNewTaskDraft = useOpenNewTaskDraft();
const navigation = useNavigation();
const environmentOptions = useEnvironmentOptions().filter(
(option) => option.newProjectsRoot !== null,
);
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(
() => stringParam(props.environmentId) as EnvironmentId | null,
);
const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId);
const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
const checkPublishedAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, {
reportFailure: false,
reportDefect: false,
});
const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
reportFailure: false,
});
const discoveryState = useEnvironmentQuery(
environment === null
? null
: sourceControlEnvironment.discovery({
environmentId: environment.environmentId,
input: {},
}),
);
const publishTargets = getNewProjectPublishTargets(discoveryState.data);
const [name, setName] = useState("");
const [publishes, setPublishes] = useState(false);
const [publishKind, setPublishKind] = useState(null);
const publishTarget = getNewProjectPublishTarget(publishTargets, publishKind);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const trimmedName = name.trim();
const pathPreview =
environment?.newProjectsRoot != null
? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName)
: null;
const showMachines =
environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0);
const machineRows = showMachines ? (
{environmentOptions.map((option, index) => {
const selected = option.environmentId === environment?.environmentId;
return (
}
selected={selected}
disabled={isSubmitting}
isFirst={index === 0}
right={
selected ? (
) : null
}
onPress={() => setSelectedEnvironmentId(option.environmentId)}
/>
);
})}
) : null;
// State lags a render behind, so a double tap could start a second create.
const submittingRef = useRef(false);
const submit = async () => {
if (!environment || trimmedName.length === 0 || submittingRef.current) return;
const originRoute = getActiveRoute(navigation);
submittingRef.current = true;
setError(null);
setIsSubmitting(true);
const result = await createNew({
environmentId: environment.environmentId,
input: { name: trimmedName },
});
if (AsyncResult.isFailure(result)) {
submittingRef.current = false;
setIsSubmitting(false);
setError(errorMessage(Cause.squash(result.cause)));
return;
}
const { projectId, workspaceRoot, commitError } = result.value;
if (commitError !== undefined) {
Dialog.alert("Created without a first commit", commitError);
}
if (publishes && publishTarget !== null) {
void publishRepository({
environmentId: environment.environmentId,
input: {
cwd: workspaceRoot,
provider: publishTarget.definition.kind,
repository: getNewProjectRepository(publishTarget, workspaceRoot),
visibility: "private",
},
}).then((publishResult) => {
if (AsyncResult.isFailure(publishResult)) {
Dialog.alert(
`Could not create the ${publishTarget.definition.label} repository`,
errorMessage(Cause.squash(publishResult.cause)),
);
} else {
void suggestAppAccessForRemote(
checkPublishedAppAccess,
environment.environmentId,
publishResult.value.remoteUrl,
);
}
});
}
// The draft screen resolves its project from the client store, so it
// must not open before the create event has arrived.
const project = await waitForProject(
{ environmentId: environment.environmentId, projectId },
15_000,
);
submittingRef.current = false;
setIsSubmitting(false);
if (project === null) {
setName("");
setError(
"The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
);
return;
}
openNewTaskDraft(
{
environmentId: environment.environmentId,
projectId,
title: trimmedName,
},
originRoute,
);
};
return (
{error ? : null}
{environment ? (
<>
void submit()}
/>
{pathPreview !== null ? (
{trimmedName.length > 0
? `Creates ${pathPreview}`
: `Goes in ${environment.newProjectsRoot}`}
{showMachines ? ` on ${environment.label}` : null}
) : null}
{machineRows}
{publishTarget !== null ? (
0 && pathPreview !== null
? getNewProjectRepository(publishTarget, pathPreview)
: publishTarget.owner
}
icon={
}
isFirst
right={
}
onPress={() => setPublishes((current) => !current)}
/>
{publishes && publishTargets.length > 1
? publishTargets.map((target) => {
const selected = target.definition.kind === publishTarget.definition.kind;
return (
}
selected={selected}
disabled={isSubmitting}
right={
selected ? (
) : null
}
onPress={() => setPublishKind(target.definition.kind)}
/>
);
})
: null}
) : null}
void submit()}
loading={isSubmitting}
/>
}
isFirst
onPress={() =>
navigation.canGoBack()
? navigation.goBack()
: navigation.dispatch(StackActions.replace("AddProject"))
}
/>
>
) : environmentOptions.length > 0 ? (
machineRows
) : (
)}
);
}
export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
const environment = useEnvironmentFromParam(props.environmentId);
const canCreateProject = useEnvironmentScope(
environment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const createProject = useCreateProject(environment);
const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
useBrowsePathInput(environment);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const submitPath = 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);
};
return (
{error ? : null}
{environment ? (
<>
{!canCreateProject ? (
) : null}
>
) : (
)}
);
}
export function AddProjectDestinationScreen(props: {
readonly environmentId?: string | string[];
readonly remoteUrl?: string | string[];
readonly repositoryTitle?: string | string[];
readonly repositoryName?: string | string[];
readonly prompt?: string | string[];
}) {
const openNewTaskDraft = useOpenNewTaskDraft();
const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
reportFailure: false,
});
const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, {
reportFailure: false,
reportDefect: false,
});
const navigation = useNavigation();
const environment = useEnvironmentFromParam(props.environmentId);
const canWriteSourceControl = useEnvironmentScope(
environment?.environmentId ?? null,
AuthSourceControlWriteScope,
);
const canCreateProject = useEnvironmentScope(
environment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const canCloneProject = canWriteSourceControl && canCreateProject;
const remoteUrl = stringParam(props.remoteUrl);
const repositoryTitle = stringParam(props.repositoryTitle);
const prompt = stringParam(props.prompt);
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 = async () => {
if (
!environment ||
!readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
!readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
!remoteUrl ||
isBrowseNavigating ||
isSubmitting
) {
return;
}
setError(null);
const resolved = resolveAddProjectPath({
rawPath: pathInput,
currentProjectCwd: null,
platform: environment.platform,
});
if (!resolved.ok) {
setError(resolved.error);
return;
}
const originRoute = getActiveRoute(navigation);
setIsSubmitting(true);
const projectId = ProjectId.make(uuidv4());
const title = inferProjectTitleFromPath(resolved.path);
const startResult = await startProjectClone({
environmentId: environment.environmentId,
input: {
projectId,
title,
createdAt: new Date().toISOString(),
remoteUrl,
destinationPath: resolved.path,
},
});
if (AsyncResult.isFailure(startResult)) {
setError(errorMessage(Cause.squash(startResult.cause)));
} else {
void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl);
// Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls.
const project = await waitForProject(
{ environmentId: environment.environmentId, projectId },
15_000,
);
if (project === null) {
setError(
"The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
);
} else {
openNewTaskDraft(
{
environmentId: environment.environmentId,
projectId,
title,
cloning: "1",
...(prompt
? await draftWithPrompt(environment.environmentId, projectId, prompt).then(
(draftId) => ({ draftId }),
() => ({}),
)
: {}),
},
originRoute,
);
}
}
setIsSubmitting(false);
};
return (
{error ? : null}
{repositoryTitle ? (
{repositoryTitle}
{remoteUrl}
) : null}
{environment ? (
<>
{!canCloneProject ? (
This connection cannot clone projects.
) : null}
>
) : (
)}
);
}