import Stack from "expo-router/stack"; import { SymbolView } from "expo-symbols"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { EnvironmentId, type ProjectListEntriesResult, type ProjectReadFileResult, ThreadId, } from "@t3tools/contracts"; import { AppText as Text } from "../../components/AppText"; import { CopyTextButton } from "../../components/CopyTextButton"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { cn } from "../../lib/cn"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { buildThreadFilesNavigation } from "../../lib/routes"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; 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 { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, fileBreadcrumbs, 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 ModeButton(props: { readonly active: boolean; readonly icon: "doc.text" | "eye"; readonly label: string; readonly onPress: () => void; }) { const iconColor = String( useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), ); return ( {props.label} ); } function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { const gradientId = `file-breadcrumb-${props.side}-fade`; const isLeft = props.side === "left"; return ( ); } function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { const iconColor = String(useThemeColor("--color-icon-muted")); const cardColor = String(useThemeColor("--color-card")); const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); const breadcrumbs = useMemo( () => fileBreadcrumbs(props.projectName, props.relativePath), [props.projectName, props.relativePath], ); const updateFadeVisibility = useCallback( (metrics: Partial<(typeof scrollMetrics)["current"]>) => { Object.assign(scrollMetrics.current, metrics); const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; const maxOffset = Math.max(0, contentWidth - viewportWidth); const next = { left: maxOffset > 1 && offsetX > 1, right: maxOffset > 1 && offsetX < maxOffset - 1, }; setFadeVisibility((current) => current.left === next.left && current.right === next.right ? current : next, ); }, [], ); return ( { updateFadeVisibility({ contentWidth }); }} onLayout={(event) => { updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); }} onScroll={(event) => { updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); }} scrollEventThrottle={16} > {breadcrumbs.map((crumb, index) => ( {index > 0 ? ( ) : null} {crumb.label} ))} {fadeVisibility.left ? : null} {fadeVisibility.right ? : null} ); } function FilePreviewHeader(props: { readonly activeMode: FileViewMode; readonly showModeSelector: boolean; readonly externalPreviewUri?: string | null; readonly projectName: string; readonly relativePath: string; readonly onSetMode: (mode: FileViewMode) => void; }) { const iconColor = String(useThemeColor("--color-icon-muted")); return ( {props.showModeSelector ? ( props.onSetMode("preview")} /> props.onSetMode("source")} /> {props.externalPreviewUri !== undefined ? ( { if (typeof props.externalPreviewUri === "string") { void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); } }} > ) : null} ) : null} ); } 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; }) { 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 ? ( ) : ( )} ); } function useThreadFilesWorkspace() { const params = useLocalSearchParams<{ environmentId?: string | string[]; 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 FilesHeaderTitle(props: { readonly projectName: string }) { const foregroundColor = String(useThemeColor("--color-foreground")); const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary")); return ( Files {props.projectName} ); } function FilesToolbarBottomFade() { const sheetColor = String(useThemeColor("--color-sheet")); if (process.env.EXPO_OS !== "ios") { return null; } return ( ); } export function ThreadFilesTreeScreen() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); const entriesQuery = useEnvironmentQuery( environmentId !== null && cwd !== null ? projectEnvironment.listEntries({ environmentId, input: { cwd }, }) : null, ); const entriesData = entriesQuery.data as ProjectListEntriesResult | null; const handleSelectFile = useCallback( (path: string) => { if (environmentId === null || threadId === null) { return; } router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); }, [environmentId, router, threadId], ); if (selectedThread === null || environmentId === null || threadId === null) { return ; } if (cwd === null) { return ; } return ( , headerSearchBarOptions: { allowToolbarIntegration: true, autoCapitalize: "none", hideNavigationBar: false, placeholder: "Search files", onChangeText: (event) => { setSearchQuery(event.nativeEvent.text); }, onCancelButtonPress: () => { setSearchQuery(""); }, }, }} /> ); } export function ThreadFileScreen() { const params = useLocalSearchParams<{ line?: string | string[]; path?: string | string[]; }>(); const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); 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; if (selectedThread === null || environmentId === null || threadId === null) { return ; } if (cwd === null) { return ; } if (relativePath === null) { return ( ); } return ( { if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { setPreviewRevision((current) => current + 1); return; } fileQuery.refresh(); }} /> { setModeOverride({ path: relativePath, mode }); }} /> ); }