1002 lines · 33.2 KB · TypeScript
RawDownload
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 (    <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted">      {props.children}    </Text>  );} 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).    <View collapsable={false} className="flex-1 bg-sheet">      <ScrollView        keyboardShouldPersistTaps="handled"        contentInsetAdjustmentBehavior="automatic"        contentContainerStyle={{          paddingHorizontal: 20,          paddingTop: 16,          paddingBottom: Math.max(insets.bottom, 18) + 18,          gap: 10,        }}      >        {props.children}      </ScrollView>    </View>  );} function ListSection(props: { readonly children: ReactNode }) {  return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>;} 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 (    <Pressable      disabled={props.disabled}      onPress={props.onPress}      className={cn(        "bg-card px-3.5 py-2.5 active:opacity-70",        !props.isFirst && "border-t border-border-subtle",        props.disabled && "opacity-[0.45]",      )}    >      <View className="flex-row items-center gap-3">        <View          className={            props.selected              ? "h-7 w-7 items-center justify-center rounded-full bg-primary"              : "h-7 w-7 items-center justify-center"          }        >          {props.icon}        </View>        <View className="flex-1 gap-0.5">          <Text className="text-base leading-snug font-t3-bold">{props.title}</Text>          {props.subtitle ? (            <Text className="text-sm leading-snug text-foreground-muted" numberOfLines={2}>              {props.subtitle}            </Text>          ) : null}        </View>        {"right" in props ? (          props.right        ) : !props.disabled ? (          <SymbolView            name="chevron.right"            size={13}            tintColorClassName={"accent-chevron"}            type="monochrome"          />        ) : null}      </View>    </Pressable>  );} function PrimaryActionButton(props: {  readonly label: string;  readonly disabled?: boolean;  readonly loading?: boolean;  readonly onPress: () => void;}) {  return (    <Pressable      disabled={props.disabled}      onPress={props.onPress}      className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45"    >      {props.loading ? (        <ActivityIndicator colorClassName={String("accent-primary-foreground")} />      ) : (        <Text className="text-base font-t3-bold text-primary-foreground">{props.label}</Text>      )}    </Pressable>  );} function ProjectPathInput(props: {  readonly value: string;  readonly onChangeText: (value: string) => void;  readonly onSubmit: () => void;}) {  return (    <TextInput      className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"      value={props.value}      onChangeText={props.onChangeText}      autoCapitalize="none"      autoCorrect={false}      placeholder="~/projects/my-app"      returnKeyType="done"      onSubmitEditing={props.onSubmit}    />  );} // `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<EnvironmentOption> {  const serverConfigByEnvironmentId = useServerConfigs();  const { savedConnectionsById } = useSavedRemoteConnections();  const { connectedEnvironments } = useRemoteConnectionStatus();   return useMemo<ReadonlyArray<EnvironmentOption>>(() => {    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<EnvironmentOption>;  readonly selectedEnvironment: EnvironmentOption | null;  readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void;} {  const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(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 (    <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8">      <Text className="text-center text-lg font-t3-bold">Environment unavailable</Text>      <Text className="text-center text-sm leading-normal text-foreground-muted">        Start or reconnect an environment before adding a project.      </Text>      <Pressable        onPress={() => navigation.dispatch(StackActions.replace("ConnectionsNew"))}        className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"      >        <Text className="text-sm font-t3-bold text-primary-foreground">Add environment</Text>      </Pressable>    </View>  );} 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" ? (      <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" />    ) : (      <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" />    );   if (!props.ready) {    return (      <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} />    );  }   return (    <ListRow      title={title}      subtitle={subtitle}      icon={icon}      isFirst={props.isFirst}      onPress={() =>        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 (    <AddProjectShell>      {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}       {environmentOptions.length > 1 ? (        <>          <SectionTitle>Environments</SectionTitle>          <ListSection>            {environmentOptions.map((environment, index) => (              <ListRow                key={environment.environmentId}                title={environment.label}                subtitle={                  canCreateProjectInEnvironment(environment.connectionState)                    ? environment.environmentId                    : connectionStatusText({                        phase: environment.connectionState,                        error: environment.connectionError,                        traceId: environment.connectionErrorTraceId,                      })                }

Lines 1–500 of 1002.