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. Load more
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({