965 lines · 33.6 KB · TypeScript
RawDownload
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";import type { MenuAction } from "@react-native-menu/menu";import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";import { ActivityIndicator, Platform, 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 { videoMimeType } from "@t3tools/shared/video";import {  isWorkspaceBrowserPreviewPath,  isWorkspaceImagePreviewPath,  mediaMimeTypeFromExtension,} from "@t3tools/shared/filePreview";import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { SymbolView } from "../../components/AppSymbol";import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";import { ControlPillMenu } from "../../components/ControlPill";import { EmptyState } from "../../components/EmptyState";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { LoadingScreen } from "../../components/LoadingScreen";import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";import { isPdfFile } from "../../lib/filePreview";import { tryOpenExternalUrl } from "../../lib/openExternalUrl";import { useUniwindTheme } from "../../lib/useUniwindTheme";import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions";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 type { AssetUrlFailureReason } from "../../state/asset-url-state";import {  useAdaptiveWorkspaceLayout,  useAdaptiveWorkspacePaneRole,  useRegisterWorkspaceInspector,} from "../layout/AdaptiveWorkspaceLayout";import {  createNativeMailSearchToolbarItem,  NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,} from "../layout/native-mail-search-toolbar";import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";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 { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";import {  basename,  isAbsolutePath,  isMarkdownPreviewFile,  isSvgImagePreviewFile,  isVideoPreviewFile,} from "./filePath";import { useWorkspaceFileAssetUrlState } 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 &&    (isWorkspaceBrowserPreviewPath(path) ||      isWorkspaceImagePreviewPath(path) ||      isVideoPreviewFile(path))    ? "preview"    : "source";} function FileContent(props: {  readonly activeMode: FileViewMode;  readonly cwd: string;  readonly environmentId: EnvironmentId;  readonly previewUri: string | null;  readonly previewFailure: AssetUrlFailureReason | null;  readonly onRetryPreview: () => void;  readonly videoSource: MediaVideoPreviewSource | null;  readonly mediaSource?: MediaActionsSource;  readonly resolveVideoUri: () => Promise<string | null>;  readonly fileContents: string | null;  readonly fileError: string | null;  readonly relativePath: string;  readonly threadId: ThreadId;  readonly initialLine: number | null;  readonly truncated: boolean;  readonly onRefresh?: () => Promise<void> | void;}) {  // Reopening a mutable host file must not reuse a poster from an earlier visit.  const thumbnailInstanceId = useId();  const isMarkdown = isMarkdownPreviewFile(props.relativePath);  const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);  const isImageFile = isWorkspaceImagePreviewPath(props.relativePath);  const isVideoFile = isVideoPreviewFile(props.relativePath);  // Only the surfaces that wait on a signed asset URL can be blocked by one.  const needsAssetUrl =    isVideoFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile));   if (needsAssetUrl && props.previewFailure !== null) {    return (      <WorkspaceFilePreviewError        environmentId={props.environmentId}        reason={props.previewFailure}        onRetry={props.onRetryPreview}      />    );  }   if (isVideoFile) {    return (      <WorkspaceFileVideoPreview        name={basename(props.relativePath)}        thumbnailKey={`workspace-video:${thumbnailInstanceId}`}        uri={props.previewUri}        source={props.videoSource}        resolvePlaybackUri={props.resolveVideoUri}      />    );  }   if (props.activeMode === "preview" && isImageFile) {    if (isSvgImagePreviewFile(props.relativePath)) {      return <WorkspaceFileWebPreview uri={props.previewUri} />;    }    return (      <WorkspaceFileImagePreview        accessibilityLabel={basename(props.relativePath)}        uri={props.previewUri}        actionsSource={props.mediaSource}      />    );  }   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-warning-border bg-warning px-4 py-2">          <Text className="text-2xs font-t3-bold uppercase text-warning-foreground">            Partial file          </Text>          <Text className="text-xs leading-snug text-warning-foreground">            Preview limited to the first 1 MB of a truncated file.          </Text>        </View>      ) : null}      {props.activeMode === "preview" && isMarkdown ? (        <FileMarkdownPreview          cwd={props.cwd}          environmentId={props.environmentId}          markdown={props.fileContents}          relativePath={props.relativePath}          threadId={props.threadId}          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(useUniwindTheme()["--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 isAndroid = Platform.OS === "android";  const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } =    useAppearancePreferences();  const theme = useUniwindTheme();  const screenColor = theme["--color-screen"];  const sheetSurfaceColor = theme["--color-sheet-solid"];  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 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;   const content = (    <>      {/* Static header config (glass preset and title) lives in Stack.tsx. The          live sheet color stays dynamic here so the FlatList can remain the          direct scene child for native scroll-edge sampling. */}      <NativeStackScreenOptions        options={{          contentStyle: {            backgroundColor: materialYouStyleLayoutActive ? screenColor : sheetSurfaceColor,          },          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}            hideBottomBorder={materialYouStyleLayoutActive}            actions={[              {                accessibilityLabel: "Refresh files",                icon: "arrow.clockwise",                onPress: entriesQuery.refresh,              },            ]}          />          <View            className={              materialYouStyleLayoutActive                ? "mx-4 my-2 min-h-12 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3.5"                : "flex-row items-center gap-2 border-b border-border px-3 py-2"            }          >            <SymbolView              name="magnifyingglass"              size={17}              tintColorClassName={"accent-icon-muted"}              type="monochrome"            />            <TextInput              accessibilityLabel="Search files"              autoCapitalize="none"              autoCorrect={false}              className={                materialYouStyleLayoutActive

Lines 1–500 of 965.