322 lines · 11.9 KB · TypeScript
RawDownload
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import {  StackActions,  useIsFocused,  useNavigation,  type StaticScreenProps,} from "@react-navigation/native";import { SymbolView } from "../../components/AppSymbol";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { useEffect, useRef } from "react";import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { AppText as Text } from "../../components/AppText";import { ProjectFavicon } from "../../components/ProjectFavicon";import { useProjects } from "../../state/entities";import type { WorkspaceState } from "../../state/workspaceModel";import { useWorkspaceState } from "../../state/workspace";import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";import { useIncomingShare } from "../sharing/IncomingShareProvider";import { useNewTaskFlow } from "./new-task-flow-provider";import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; type NewTaskRouteParams = {  readonly incomingShareId?: string | string[];}; function deriveProjectEmptyState(catalogState: WorkspaceState): {  readonly title: string;  readonly detail: string;  readonly loading: boolean;} {  if (catalogState.isLoadingConnections) {    return {      title: "Loading environments",      detail: "Checking saved environments on this device.",      loading: true,    };  }   if (!catalogState.hasConnections) {    return {      title: "No environments connected",      detail: "Add an environment before creating a task.",      loading: false,    };  }   if (    (catalogState.connectionState === "available" ||      catalogState.connectionState === "offline" ||      catalogState.connectionState === "error") &&    !catalogState.hasLoadedShellSnapshot  ) {    return {      title: "Environment unavailable",      detail:        catalogState.connectionError ??        "The saved environment is offline. Check the URL or start the environment, then retry.",      loading: false,    };  }   if (    catalogState.hasConnectingEnvironment &&    !catalogState.hasLoadedShellSnapshot &&    catalogState.connectionError === null  ) {    return {      title: "Connecting to environment",      detail: "Loading projects from the saved environment.",      loading: true,    };  }   return {    title: "No projects found",    detail: "The connected environment did not report any projects.",    loading: false,  };} export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {  const projects = useProjects();  const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();  const { state: catalogState } = useWorkspaceState();  const navigation = useNavigation();  const isFocused = useIsFocused();  const { layout } = useAdaptiveWorkspaceLayout();  const insets = useSafeAreaInsets();  const { getShare, releaseShareReservation } = useIncomingShare();  const routeShareId = Array.isArray(route.params?.incomingShareId)    ? route.params.incomingShareId[0]    : route.params?.incomingShareId;  const incomingShare = routeShareId ? getShare(routeShareId) : null;  const incomingShareSubtitle = incomingShare    ? incomingShare.attachments.length === 0      ? "Choose a project for what you shared"      : incomingShare.attachments.length === 1        ? `Choose a project for the ${incomingShare.attachments[0]?.type === "image" ? "image" : "file"} you shared`        : `Choose a project for the ${incomingShare.attachments.length} ${incomingShare.attachments.every((attachment) => attachment.type === "image") ? "images" : "files"} you shared`    : null;  const screenTitle = incomingShare ? "Start a task" : "Choose project";  const projectEmptyState = deriveProjectEmptyState(catalogState);  const resumedDestinationKeyRef = useRef<string | null>(null);  const reservedDestinationProject = incomingShare?.destination    ? (projects.find(        (project) =>          project.environmentId === incomingShare.destination?.environmentId &&          project.id === incomingShare.destination?.projectId,      ) ?? null)    : null;   async function selectProject(project: EnvironmentProject): Promise<void> {    if (incomingShare?.destination && !reservedDestinationProject) {      try {        await releaseShareReservation(incomingShare.id, incomingShare.destination);      } catch (error) {        Alert.alert(          "Could not change project",          error instanceof Error            ? error.message            : "The shared content reservation could not be updated.",        );        return;      }    }    const state = navigation.getState();    const previousRoute = state?.routes[state.index - 1];    if (previousRoute?.name === "NewTaskDraft") {      setProject(project);      navigation.goBack();      return;    }     navigation.dispatch(      StackActions.push("NewTaskDraft", {        environmentId: project.environmentId,        projectId: project.id,        title: project.title,        incomingShareId: incomingShare?.id,      }),    );  }   useEffect(() => {    const destination = incomingShare?.destination;    if (!destination) {      resumedDestinationKeyRef.current = null;      return;    }    if (!isFocused) {      // Returning from the reserved draft is a fresh resume attempt. Keeping      // this latch set would leave every project row disabled with no route.      resumedDestinationKeyRef.current = null;      return;    }    const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`;    if (resumedDestinationKeyRef.current === destinationKey) {      return;    }    if (!reservedDestinationProject) {      return;    }    resumedDestinationKeyRef.current = destinationKey;    navigation.dispatch(      StackActions.push("NewTaskDraft", {        environmentId: reservedDestinationProject.environmentId,        projectId: reservedDestinationProject.id,        title: reservedDestinationProject.title,        incomingShareId: incomingShare.id,      }),    );  }, [incomingShare, isFocused, navigation, reservedDestinationProject]);   return (    <View collapsable={false} className="flex-1 bg-sheet">      {Platform.OS === "android" ? (        <>          {/* Android renders its own in-screen header instead of the native bar. */}          <NativeStackScreenOptions options={{ headerShown: false }} />          <AndroidScreenHeader            title={screenTitle}            subtitle={incomingShareSubtitle}            onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}            actions={              catalogState.hasReadyEnvironment                ? [                    {                      accessibilityLabel: "Add project",                      icon: "plus",                      onPress: () => navigation.dispatch(StackActions.push("AddProject")),                    },                  ]                : []            }          />        </>      ) : (        <>          <NativeStackScreenOptions            options={{              title: screenTitle,              unstable_headerSubtitle: incomingShareSubtitle ?? undefined,            }}          />          <NativeHeaderToolbar placement="right">            {layout.usesSplitView ? (              <NativeHeaderToolbar.Button                accessibilityLabel="Close new task"                icon="xmark"                onPress={() => navigation.goBack()}                separateBackground              />            ) : null}            {catalogState.hasReadyEnvironment ? (              <NativeHeaderToolbar.Button                icon="plus"                onPress={() => navigation.dispatch(StackActions.push("AddProject"))}                separateBackground              />            ) : null}          </NativeHeaderToolbar>        </>      )}       <ScrollView        contentInsetAdjustmentBehavior="automatic"        showsVerticalScrollIndicator={false}        className="flex-1"        contentContainerStyle={{          gap: 12,          paddingBottom: Math.max(insets.bottom, 18) + 18,          paddingHorizontal: 20,          paddingTop: 8,        }}      >        {projectScopes.length === 0 ? (          <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">            {projectEmptyState.loading ? (              <ActivityIndicator colorClassName={"accent-icon-muted"} />            ) : null}            <Text className="text-center text-lg font-t3-bold text-foreground">              {projectEmptyState.title}            </Text>            <Text className="text-center text-sm leading-normal text-foreground-muted">              {projectEmptyState.detail}            </Text>            {!catalogState.hasReadyEnvironment ? (              <Pressable                className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"                onPress={() => navigation.navigate("ConnectionsNew")}              >                <Text className="text-sm font-t3-bold text-primary-foreground">                  Add environment                </Text>              </Pressable>            ) : (              <Pressable                className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"                onPress={() => navigation.dispatch(StackActions.push("AddProject"))}              >                <Text className="text-sm font-t3-bold text-primary-foreground">                  Add new project                </Text>              </Pressable>            )}          </View>        ) : (          <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">            {projectScopes.map((scope, scopeIndex) => {              const hasMultipleProjects = scope.projects.length > 1;              const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);              return (                <View                  key={scope.key}                  className={cn(scopeIndex > 0 && "border-t border-border-subtle")}                >                  <Pressable                    disabled={reservedDestinationProject !== null}                    onPress={() => void selectProject(selectionTarget)}                    className="flex-row items-center gap-3 bg-card px-4 py-3.5"                  >                    <View className="h-7 w-7 items-center justify-center">                      <ProjectFavicon                        environmentId={scope.representative.environmentId}                        faviconPath={scope.representative.faviconPath}                        size={20}                        projectTitle={scope.title}                        workspaceRoot={scope.representative.workspaceRoot}                      />                    </View>                    <View className="min-w-0 flex-1">                      <Text className="text-base leading-snug font-t3-bold">{scope.title}</Text>                      <Text                        className="text-xs leading-snug text-foreground-muted"                        ellipsizeMode="middle"                        numberOfLines={1}                      >                        {hasMultipleProjects                          ? `${scope.projects.length} workspaces`                          : selectionTarget.workspaceRoot}                      </Text>                    </View>                    <SymbolView                      name="chevron.right"                      size={14}                      tintColorClassName={"accent-chevron"}                      type="monochrome"                    />                  </Pressable>                </View>              );            })}          </View>        )}      </ScrollView>    </View>  );}