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={ materialYouStyleLayoutActiveLines 1–500 of 965. Load more
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