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, 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, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; 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 { 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; }) { 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 ; } return ( ); } if (props.activeMode === "preview" && isBrowserFile) { return ; } if (props.fileError && props.fileContents === null) { return ( ); } if (props.fileContents === null) { return ( Loading file... ); } return ( {props.truncated ? ( Partial file Preview limited to the first 1 MB of a truncated file. ) : null} {props.activeMode === "preview" && isMarkdown ? ( ) : ( )} ); } 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 ( ); } function FilesToolbarBottomFade() { const sheetColor = String(useThemeColor("--color-sheet")); if (process.env.EXPO_OS !== "ios") { return null; } return ( ); } 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 } = useAppearancePreferences(); const iconColor = String(useThemeColor("--color-icon-muted")); const sheetSurfaceColor = String(useThemeColor("--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 ? ( ) : 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 ( ); } return ; } if (cwd === null) { return ; } if (fileInspector.supported) { return ( ); } const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; return ( <> {/* 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. */} 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 ? ( <> ) : ( <> {layout.usesSplitView ? ( ) : null} {usesCompactMailToolbar ? null : ( )} )} ); } 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({ environmentId, input: { cwd, relativePath }, }) : null, ); const fileData = fileQuery.data as ProjectReadFileResult | null; const handleSelectFile = useCallback( (path: string) => { navigation.navigate("ThreadFile", { environmentId: String(environmentId), threadId: String(threadId), path: path.split("/").filter(Boolean), }); }, [environmentId, navigation, threadId], ); const renderInspector = useCallback( (headerInset: number) => fileInspector.supported && environmentId !== null && cwd !== null ? ( ) : undefined, [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], ); // The workspace inspector column spans the full window height. On iOS the // pane brings its own nested native header; elsewhere it pads itself below // the top inset. const safeAreaInsets = useSafeAreaInsets(); const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; // Hand the file navigator to the workspace so it renders beside the // navigator, outside this screen's native header. const renderWorkspaceInspector = useCallback( () => renderInspector(inspectorHeaderInset), [inspectorHeaderInset, renderInspector], ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); if (selectedThread === null || environmentId === null || threadId === null) { return ; } if (cwd === null) { return ; } if (relativePath === null) { return ( ); } const parentDir = relativePath.split("/").slice(0, -1).join("/"); const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · "); return ( 0 ? headerSubtitle : undefined, }} /> {fileInspector.supported ? ( { navigation.dispatch( StackActions.replace("Thread", { environmentId: String(environmentId), threadId: String(threadId), }), ); }} /> ) : null} {fileInspector.supported ? ( ) : null} {canPreview && !isImageFile ? ( setModeOverride({ path: relativePath, mode: "preview" })} > Preview setModeOverride({ path: relativePath, mode: "source" })} > Source ) : null} copyTextWithHaptic(relativePath)} > Copy path {isBrowserFile && typeof assetPreviewUri === "string" ? ( { void tryOpenExternalUrl(assetPreviewUri, "file-preview"); }} > Open in Safari ) : null} {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( { setPreviewRevision((current) => current + 1); }} > Refresh ) : null} fileQuery.refresh()} /> ); }