838 lines · 26.7 KB · TypeScript
RawDownload
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 (    <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;}) {  const chevronColor = useThemeColor("--color-chevron");   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} tintColor={chevronColor} type="monochrome" />        ) : null}      </View>    </Pressable>  );} function PrimaryActionButton(props: {  readonly label: string;  readonly disabled?: boolean;  readonly loading?: boolean;  readonly onPress: () => void;}) {  const primaryForeground = useThemeColor("--color-primary-foreground");   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 color={String(primaryForeground)} />      ) : (        <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}    />  );} function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {  const serverConfigByEnvironmentId = useServerConfigs();  const { savedConnectionsById } = useSavedRemoteConnections();   return useMemo<ReadonlyArray<EnvironmentOption>>(() => {    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<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) ??    environmentOptions[0] ??    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">No environments connected</Text>      <Text className="text-center text-sm leading-normal text-foreground-muted">        Add 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 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" ? (      <SymbolView name="link" size={17} tintColor={iconColor} type="monochrome" />    ) : (      <SourceControlIcon kind={props.source} size={18} color={String(iconColor)} />    );   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.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 (    <AddProjectShell>      {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}       {environmentOptions.length > 1 ? (        <>          <SectionTitle>Connected environments</SectionTitle>          <ListSection>            {environmentOptions.map((environment, index) => (              <ListRow                key={environment.environmentId}                title={environment.label}                subtitle={environment.environmentId}                icon={                  <SymbolView                    name="server.rack"                    size={17}                    tintColor={iconColor}                    type="monochrome"                  />                }                selected={environment.environmentId === selectedEnvironment?.environmentId}                isFirst={index === 0}                right={                  environment.environmentId === selectedEnvironment?.environmentId ? (                    <SymbolView                      name="checkmark"                      size={14}                      tintColor={iconColor}                      type="monochrome"                    />                  ) : null                }                onPress={() => setSelectedEnvironmentId(environment.environmentId)}              />            ))}          </ListSection>        </>      ) : null}       {selectedEnvironment ? (        <>          <ListSection>            <ListRow              title="Local folder"              subtitle="Browse a folder on disk"              icon={                <SymbolView                  name="folder.badge.plus"                  size={17}                  tintColor={iconColor}                  type="monochrome"                />              }              isFirst              onPress={() =>                navigation.navigate("NewTaskSheet", {                  screen: "AddProjectLocal",                  params: {                    environmentId: selectedEnvironment.environmentId,                  },                })              }            />            {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(              (candidate) => (                <SourceControlRow                  key={candidate}                  source={candidate}                  selectedEnvironmentId={selectedEnvironment.environmentId}                  ready={readiness[candidate].ready}                  hint={                    readiness[candidate].ready                      ? addProjectRemoteSourcePathHint(candidate)                      : (readiness[candidate].hint ?? "")                  }                  isFirst={false}                />              ),            )}          </ListSection>          {discoveryState.isPending ? <ActivityIndicator color={accentColor} /> : null}        </>      ) : null}    </AddProjectShell>  );} 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) ??

Lines 1–500 of 838.