667 lines · 23.6 KB · TypeScript
RawDownload
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";import { useCallback, useEffect, useRef, useState } from "react";import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";import {  EnvironmentId,  type ProjectListEntriesResult,  type ProjectReadFileResult,  ThreadId,} from "@t3tools/contracts"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { SymbolView } from "../../components/AppSymbol";import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";import { EmptyState } from "../../components/EmptyState";import { LoadingScreen } from "../../components/LoadingScreen";import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";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 {  useAdaptiveWorkspaceLayout,  useAdaptiveWorkspacePaneRole,  useRegisterWorkspaceInspector,} from "../layout/AdaptiveWorkspaceLayout";import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";import { FileMarkdownPreview } from "./FileMarkdownPreview";import { FileTreeBrowser } from "./FileTreeBrowser";import { preloadWorkspaceFileContents } from "./preload-workspace-file";import { SourceFileSurface } from "./SourceFileSurface";import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";import {  basename,  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 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;  readonly onRefresh?: () => Promise<void> | void;}) {  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-sheet 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-sheet px-6">        <ActivityIndicator />        <Text className="text-center text-sm text-foreground-muted">Loading file...</Text>      </View>    );  }   return (    <View className="flex-1 bg-sheet">      {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-snug 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} onRefresh={props.onRefresh} />      ) : (        <SourceFileSurface          contents={props.fileContents}          path={props.relativePath}          initialLine={props.initialLine}          onRefresh={props.onRefresh}        />      )}    </View>  );} type ThreadFilesRouteScreenProps = StaticScreenProps<{  readonly environmentId: string;  readonly threadId: string;}>; type ThreadFileRouteScreenProps = StaticScreenProps<{  readonly environmentId: string;  readonly threadId: string;  readonly path: string[];  readonly line?: string;}>; function useThreadFilesWorkspace(params: {  readonly environmentId?: string | string[];  readonly 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">      <NativeStackScreenOptions options={{ title: "Files" }} />      <EmptyState        title="Files unavailable"        detail="This thread does not have an active workspace path."      />    </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"      className="absolute inset-x-0 bottom-0 z-[1] h-28"    >      <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(props: ThreadFilesRouteScreenProps) {  useAdaptiveWorkspacePaneRole("inspector");  const navigation = useNavigation();  const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =    useAdaptiveWorkspaceLayout();  const [searchQuery, setSearchQuery] = useState("");  const colorScheme = useColorScheme();  const isAndroid = Platform.OS === "android";  const highlightTheme = colorScheme === "dark" ? "dark" : "light";  const iconColor = String(useThemeColor("--color-icon-muted"));  const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(    props.route.params,  );  const revealedInspectorRef = useRef(false);  const entriesQuery = useEnvironmentQuery(    environmentId !== null && cwd !== null && !fileInspector.supported      ? projectEnvironment.listEntries({          environmentId,          input: { cwd },        })      : null,  );  const entriesData = entriesQuery.data as ProjectListEntriesResult | null;  const handleReturnToThread = useCallback(() => {    if (navigation.canGoBack()) {      navigation.goBack();      return;    }    if (environmentId !== null && threadId !== null) {      navigation.dispatch(        StackActions.replace("Thread", {          environmentId: String(environmentId),          threadId: String(threadId),        }),      );    }  }, [environmentId, navigation, threadId]);   const handleSelectFile = useCallback(    (path: string) => {      if (environmentId === null || threadId === null) {        return;      }      const params = {        environmentId: String(environmentId),        threadId: String(threadId),        path: path.split("/").filter((segment) => segment.length > 0),      };      const navigationAction = resolveFileSelectionNavigationAction({        hasPersistentFileInspector: fileInspector.supported,      });      if (navigationAction === "replace") {        navigation.dispatch(StackActions.replace("ThreadFile", params));        return;      }      navigation.navigate("ThreadFile", params);    },    [environmentId, fileInspector.supported, navigation, threadId],  );  const renderInspector = useCallback(    (headerInset: number) =>      environmentId !== null && cwd !== null ? (        <ThreadFileNavigatorPane          cwd={cwd}          environmentId={environmentId}          headerInset={headerInset}          projectName={projectName}          selectedPath={null}          onSelectFile={handleSelectFile}        />      ) : null,    [cwd, environmentId, handleSelectFile, projectName],  );  const handlePreviewFile = useCallback(    (relativePath: string) => {      if (environmentId === null || cwd === null) {        return;      }      preloadWorkspaceFileContents({        cwd,        environmentId,        relativePath,        theme: highlightTheme,      });    },    [cwd, environmentId, highlightTheme],  );  useEffect(() => {    if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {      revealedInspectorRef.current = true;      showAuxiliaryPane("inspector");    }  }, [cwd, fileInspector.supported, showAuxiliaryPane]);   if (selectedThread === null || environmentId === null || threadId === null) {    if (fileInspector.supported) {      return (        <ThreadRouteScreen          onReturnToThread={handleReturnToThread}          renderInspector={renderInspector}          route={props.route}        />      );    }    return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;  }   if (cwd === null) {    return <FilesUnavailable />;  }   if (fileInspector.supported) {    return (      <ThreadRouteScreen        onReturnToThread={handleReturnToThread}        renderInspector={renderInspector}        route={props.route}      />    );  }   const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;   return (    <>      {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.          Only genuinely dynamic options are set here. */}      <NativeStackScreenOptions        options={{          headerShown: !isAndroid,          unstable_headerSubtitle:            Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,          // No refresh button: the list already supports pull-to-refresh.          unstable_headerToolbarItems: usesCompactMailToolbar            ? () => [                createNativeMailSearchToolbarItem({                  onSearchTextChange: setSearchQuery,                  placeholder: "Search files",                  searchTextChangeId: "files-search-text",                }),              ]            : undefined,          headerSearchBarOptions: usesCompactMailToolbar            ? undefined            : {                allowToolbarIntegration: true,                autoCapitalize: "none",                hideNavigationBar: false,                placeholder: "Search files",                onChangeText: (event) => {                  setSearchQuery(event.nativeEvent.text);                },                onCancelButtonPress: () => {                  setSearchQuery("");                },              },        }}      />      {isAndroid ? (        <>          <AndroidScreenHeader            title="Files"            subtitle={projectName}            onBack={handleReturnToThread}            actions={[              {                accessibilityLabel: "Refresh files",                icon: "arrow.clockwise",                onPress: entriesQuery.refresh,              },            ]}          />          <View className="flex-row items-center gap-2 border-b border-border px-3 py-2">            <SymbolView name="magnifyingglass" size={17} tintColor={iconColor} type="monochrome" />            <TextInput              accessibilityLabel="Search files"              autoCapitalize="none"              autoCorrect={false}              className="min-h-10 flex-1 rounded-xl py-2 text-sm"              placeholder="Search files"              value={searchQuery}              onChangeText={setSearchQuery}            />          </View>        </>      ) : (        <>          {layout.usesSplitView ? (            <NativeHeaderToolbar placement="left">              <NativeHeaderToolbar.Button                accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}                icon={                  panes.primarySidebarVisible                    ? "arrow.up.left.and.arrow.down.right"                    : "sidebar.left"                }                onPress={togglePrimarySidebar}                separateBackground              />            </NativeHeaderToolbar>          ) : null}          {usesCompactMailToolbar ? null : (            <NativeHeaderToolbar placement="bottom">              <NativeHeaderToolbar.SearchBarSlot />            </NativeHeaderToolbar>          )}        </>      )}      <FileTreeBrowser        entries={entriesData?.entries ?? []}        error={entriesQuery.error}        isPending={entriesQuery.isPending}        searchQuery={searchQuery}        selectedPath={null}        onPreviewFile={handlePreviewFile}        onRefresh={entriesQuery.refresh}        onSelectFile={handleSelectFile}      />      <FilesToolbarBottomFade />    </>  );} export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {  useAdaptiveWorkspacePaneRole("inspector");  const navigation = useNavigation();  const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();  const iconColor = useThemeColor("--color-icon");  const params = props.route.params;  const relativePath = normalizeRoutePath(params.path);  const targetLine = normalizeRouteLine(firstRouteParam(params.line));  const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(    props.route.params,  );  const [modeOverride, setModeOverride] = useState<{    readonly path: string;    readonly mode: FileViewMode;  } | null>(null);  const [previewRevision, setPreviewRevision] = useState(0);  const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);  const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);  const canPreview =    relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile);  const activeMode =    relativePath !== null && modeOverride?.path === relativePath      ? modeOverride.mode      : defaultViewMode(relativePath);  const resolvedActiveMode = canPreview ? activeMode : "source";  const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;  const assetPreviewUri = useWorkspaceFileAssetUrl({    cwd,    environmentId,    relativePath: assetPreviewPath,    threadId,  });  const previewUri =    assetPreviewUri === null || previewRevision === 0      ? assetPreviewUri      : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;  const needsFileContents =    relativePath !== null &&    (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));  const fileQuery = useEnvironmentQuery(    environmentId !== null && cwd !== null && relativePath !== null && needsFileContents      ? projectEnvironment.readFile({

Lines 1–500 of 667.