626 lines · 20.6 KB · TypeScript
RawDownload
import Stack from "expo-router/stack";import { SymbolView } from "expo-symbols";import { useLocalSearchParams, useRouter } from "expo-router";import { useCallback, useMemo, useRef, useState } from "react";import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";import {  EnvironmentId,  type ProjectListEntriesResult,  type ProjectReadFileResult,  ThreadId,} from "@t3tools/contracts"; import { AppText as Text } from "../../components/AppText";import { CopyTextButton } from "../../components/CopyTextButton";import { EmptyState } from "../../components/EmptyState";import { LoadingScreen } from "../../components/LoadingScreen";import { cn } from "../../lib/cn";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";import { buildThreadFilesNavigation } from "../../lib/routes";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import { useThemeColor } from "../../lib/useThemeColor";import { useThreadSelection } from "../../state/use-thread-selection";import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";import { useEnvironmentQuery } from "../../state/query";import { projectEnvironment } from "../../state/projects";import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";import { FileMarkdownPreview } from "./FileMarkdownPreview";import { FileTreeBrowser } from "./FileTreeBrowser";import { SourceFileSurface } from "./SourceFileSurface";import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";import {  basename,  fileBreadcrumbs,  isBrowserPreviewFile,  isImagePreviewFile,  isMarkdownPreviewFile,  isSvgImagePreviewFile,} from "./filePath";import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; type FileViewMode = "preview" | "source"; function firstRouteParam(value: string | string[] | undefined): string | null {  if (Array.isArray(value)) {    return value[0] ?? null;  }   return value ?? null;} function normalizeRoutePath(value: string | string[] | undefined): string | null {  const path = Array.isArray(value) ? value.join("/") : value;  if (path === undefined || path.trim().length === 0) {    return null;  }  return path;} function normalizeRouteLine(value: string | null): number | null {  if (value === null) {    return null;  }  const parsed = Number(value);  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;} function defaultViewMode(path: string | null): FileViewMode {  return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))    ? "preview"    : "source";} function ModeButton(props: {  readonly active: boolean;  readonly icon: "doc.text" | "eye";  readonly label: string;  readonly onPress: () => void;}) {  const iconColor = String(    useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),  );   return (    <Pressable      accessibilityRole="button"      accessibilityState={{ selected: props.active }}      className={cn(        "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70",        props.active ? "bg-primary" : "bg-subtle",      )}      onPress={props.onPress}    >      <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" />      <Text        className={cn(          "text-xs font-t3-bold",          props.active ? "text-primary-foreground" : "text-foreground-muted",        )}      >        {props.label}      </Text>    </Pressable>  );} function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {  const gradientId = `file-breadcrumb-${props.side}-fade`;  const isLeft = props.side === "left";   return (    <View      pointerEvents="none"      className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")}    >      <Svg width="100%" height="100%">        <Defs>          <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">            <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} />            <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} />          </LinearGradient>        </Defs>        <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />      </Svg>    </View>  );} function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {  const iconColor = String(useThemeColor("--color-icon-muted"));  const cardColor = String(useThemeColor("--color-card"));  const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });  const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });  const breadcrumbs = useMemo(    () => fileBreadcrumbs(props.projectName, props.relativePath),    [props.projectName, props.relativePath],  );  const updateFadeVisibility = useCallback(    (metrics: Partial<(typeof scrollMetrics)["current"]>) => {      Object.assign(scrollMetrics.current, metrics);      const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;      const maxOffset = Math.max(0, contentWidth - viewportWidth);      const next = {        left: maxOffset > 1 && offsetX > 1,        right: maxOffset > 1 && offsetX < maxOffset - 1,      };       setFadeVisibility((current) =>        current.left === next.left && current.right === next.right ? current : next,      );    },    [],  );   return (    <View className="min-w-0 flex-1">      <ScrollView        horizontal        showsHorizontalScrollIndicator={false}        onContentSizeChange={(contentWidth) => {          updateFadeVisibility({ contentWidth });        }}        onLayout={(event) => {          updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });        }}        onScroll={(event) => {          updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });        }}        scrollEventThrottle={16}      >        <View className="h-8 flex-row items-center">          {breadcrumbs.map((crumb, index) => (            <View key={crumb.path || "project"} className="flex-row items-center">              {index > 0 ? (                <SymbolView                  name="chevron.right"                  size={10}                  tintColor={iconColor}                  type="monochrome"                />              ) : null}              <Text                className={cn(                  "max-w-[180px] px-1 text-xs",                  crumb.kind === "file"                    ? "font-t3-bold text-foreground"                    : "font-t3-medium text-foreground-muted",                )}                numberOfLines={1}              >                {crumb.label}              </Text>            </View>          ))}        </View>      </ScrollView>      {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null}      {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null}    </View>  );} function FilePreviewHeader(props: {  readonly activeMode: FileViewMode;  readonly showModeSelector: boolean;  readonly externalPreviewUri?: string | null;  readonly projectName: string;  readonly relativePath: string;  readonly onSetMode: (mode: FileViewMode) => void;}) {  const iconColor = String(useThemeColor("--color-icon-muted"));   return (    <View className="border-b border-border bg-card px-3 py-2">      <View className="flex-row items-center gap-2">        <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} />        <CopyTextButton          accessibilityLabel="Copy file path"          text={props.relativePath}          tintColor={iconColor}          buttonSize={32}          iconSize={13}        />      </View>      {props.showModeSelector ? (        <View className="mt-2 flex-row items-center gap-2">          <ModeButton            active={props.activeMode === "preview"}            icon="eye"            label="Preview"            onPress={() => props.onSetMode("preview")}          />          <ModeButton            active={props.activeMode === "source"}            icon="doc.text"            label="Source"            onPress={() => props.onSetMode("source")}          />          {props.externalPreviewUri !== undefined ? (            <Pressable              accessibilityRole="button"              accessibilityLabel="Open preview in Safari"              disabled={props.externalPreviewUri === null}              hitSlop={8}              className={cn(                "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70",                props.externalPreviewUri === null && "opacity-40",              )}              onPress={() => {                if (typeof props.externalPreviewUri === "string") {                  void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");                }              }}            >              <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" />            </Pressable>          ) : null}        </View>      ) : null}    </View>  );} function FileContent(props: {  readonly activeMode: FileViewMode;  readonly previewUri: string | null;  readonly fileContents: string | null;  readonly fileError: string | null;  readonly relativePath: string;  readonly initialLine: number | null;  readonly truncated: boolean;}) {  const isMarkdown = isMarkdownPreviewFile(props.relativePath);  const isBrowserFile = isBrowserPreviewFile(props.relativePath);  const isImageFile = isImagePreviewFile(props.relativePath);   if (props.activeMode === "preview" && isImageFile) {    if (isSvgImagePreviewFile(props.relativePath)) {      return <WorkspaceFileWebPreview uri={props.previewUri} />;    }    return (      <WorkspaceFileImagePreview        accessibilityLabel={basename(props.relativePath)}        uri={props.previewUri}      />    );  }   if (props.activeMode === "preview" && isBrowserFile) {    return <WorkspaceFileWebPreview uri={props.previewUri} />;  }   if (props.fileError && props.fileContents === null) {    return (      <View className="flex-1 items-center justify-center bg-card px-6">        <EmptyState title="File unavailable" detail={props.fileError} />      </View>    );  }   if (props.fileContents === null) {    return (      <View className="flex-1 items-center justify-center gap-3 bg-card px-6">        <ActivityIndicator />        <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>      </View>    );  }   return (    <View className="flex-1 bg-card">      {props.truncated ? (        <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">          <Text className="text-2xs font-t3-bold uppercase text-amber-700 dark:text-amber-300">            Partial file          </Text>          <Text className="text-xs leading-[17px] text-amber-800 dark:text-amber-200">            Preview limited to the first 1 MB of a truncated file.          </Text>        </View>      ) : null}      {props.activeMode === "preview" && isMarkdown ? (        <FileMarkdownPreview markdown={props.fileContents} />      ) : (        <SourceFileSurface          contents={props.fileContents}          path={props.relativePath}          initialLine={props.initialLine}        />      )}    </View>  );} function useThreadFilesWorkspace() {  const params = useLocalSearchParams<{    environmentId?: string | string[];    threadId?: string | string[];  }>();  const routeEnvironmentId = firstRouteParam(params.environmentId);  const routeThreadId = firstRouteParam(params.threadId);  const { selectedThread, selectedThreadProject } = useThreadSelection();  const { selectedThreadCwd } = useSelectedThreadWorktree();  const environmentId =    routeEnvironmentId !== null      ? EnvironmentId.make(routeEnvironmentId)      : (selectedThread?.environmentId ?? null);  const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null;  const project = selectedThreadProject as {    readonly title?: string;    readonly workspaceRoot?: string;  } | null;   return {    cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null,    environmentId,    projectName: project?.title ?? "Files",    selectedThread,    threadId,  };} function FilesUnavailable() {  return (    <View className="flex-1 items-center justify-center bg-sheet px-6">      <Stack.Screen options={{ title: "Files" }} />      <EmptyState        title="Files unavailable"        detail="This thread does not have an active workspace path."      />    </View>  );} function FilesHeaderTitle(props: { readonly projectName: string }) {  const foregroundColor = String(useThemeColor("--color-foreground"));  const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));   return (    <View style={{ alignItems: "center", maxWidth: 220 }}>      <RNText        numberOfLines={1}        style={{          color: foregroundColor,          fontFamily: "DMSans_700Bold",          fontSize: MOBILE_TYPOGRAPHY.headline.fontSize,          fontWeight: "900",          letterSpacing: -0.4,        }}      >        Files      </RNText>      <RNText        numberOfLines={1}        style={{          color: secondaryForegroundColor,          fontFamily: "DMSans_500Medium",          fontSize: MOBILE_TYPOGRAPHY.label.fontSize,          fontWeight: "500",          letterSpacing: 0.2,        }}      >        {props.projectName}      </RNText>    </View>  );} function FilesToolbarBottomFade() {  const sheetColor = String(useThemeColor("--color-sheet"));   if (process.env.EXPO_OS !== "ios") {    return null;  }   return (    <View      pointerEvents="none"      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      style={{        bottom: 0,        height: 112,        left: 0,        position: "absolute",        right: 0,        zIndex: 1,      }}    >      <Svg width="100%" height="100%">        <Defs>          <LinearGradient id="files-toolbar-bottom-fade" x1="0%" x2="0%" y1="0%" y2="100%">            <Stop offset="0%" stopColor={sheetColor} stopOpacity={0} />            <Stop offset="58%" stopColor={sheetColor} stopOpacity={0.72} />            <Stop offset="100%" stopColor={sheetColor} stopOpacity={0.96} />          </LinearGradient>        </Defs>        <Rect width="100%" height="100%" fill="url(#files-toolbar-bottom-fade)" />      </Svg>    </View>  );} export function ThreadFilesTreeScreen() {  const router = useRouter();  const [searchQuery, setSearchQuery] = useState("");  const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();  const entriesQuery = useEnvironmentQuery(    environmentId !== null && cwd !== null      ? projectEnvironment.listEntries({          environmentId,          input: { cwd },        })      : null,  );  const entriesData = entriesQuery.data as ProjectListEntriesResult | null;   const handleSelectFile = useCallback(    (path: string) => {      if (environmentId === null || threadId === null) {        return;      }      router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));    },    [environmentId, router, threadId],  );   if (selectedThread === null || environmentId === null || threadId === null) {    return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;  }   if (cwd === null) {    return <FilesUnavailable />;  }   return (    <View className="flex-1 bg-sheet">      <Stack.Screen        options={{          title: "Files",          headerShown: true,          headerTransparent: true,          headerStyle: { backgroundColor: "transparent" },          headerShadowVisible: false,          headerTitle: () => <FilesHeaderTitle projectName={projectName} />,          headerSearchBarOptions: {            allowToolbarIntegration: true,            autoCapitalize: "none",            hideNavigationBar: false,            placeholder: "Search files",            onChangeText: (event) => {              setSearchQuery(event.nativeEvent.text);            },            onCancelButtonPress: () => {              setSearchQuery("");            },          },        }}      />

Lines 1–500 of 626.