import React, { useEffect, useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; import { ActivityIndicator, Image as RNImage, ScrollView as RNScrollView, Text, View, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useAppVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; interface CodeLineProps { tokens: HighlightToken[]; lineNumber: number; gutterWidth: number; highlighted: boolean; } interface FilePreviewBodyProps { preview: ExplorerFile | null; isLoading: boolean; showDesktopWebScrollbar: boolean; isMobile: boolean; location: WorkspaceFileLocation; imagePreviewUri: string | null; } function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } interface FileLineSelection { lineStart: number; lineEnd: number; } function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; } if (size < 1024 * 1024) { return `${(size / 1024).toFixed(1)} KB`; } return `${(size / (1024 * 1024)).toFixed(1)} MB`; } async function createFilePanePreview(file: FileReadResult | null): Promise<{ file: ExplorerFile | null; imageAttachment: AttachmentMetadata | null; }> { if (!file) { return { file: null, imageAttachment: null }; } const explorerFile = explorerFileFromReadResult(file); if (file.kind !== "image") { return { file: explorerFile, imageAttachment: null }; } const imageAttachment = await persistAttachmentFromBytes({ id: createPreviewAttachmentId({ mimeType: file.mime, path: file.path, size: file.size, modifiedAt: file.modifiedAt, contentLength: file.bytes.byteLength, }), bytes: file.bytes, mimeType: file.mime, fileName: getFileNameFromPath(file.path), }); return { file: explorerFile, imageAttachment, }; } function clampLineSelection(input: { lineStart?: number; lineEnd?: number; lineCount: number; }): FileLineSelection | null { if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) { return null; } const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount); const rawLineEnd = input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart; const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount); return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; } const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth, highlighted, }: CodeLineProps) { const gutterStyle = useMemo( () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], [gutterWidth], ); const lineStyle = useMemo( () => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], [highlighted], ); const keyedTokens = useMemo( () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), [tokens], ); return ( {String(lineNumber)} {keyedTokens.map(({ key, token }) => ( ))} ); }); interface CodeLineTokenProps { token: HighlightToken; } function CodeLineToken({ token }: CodeLineTokenProps) { return {token.text}; } const codeLineStyles = StyleSheet.create((theme) => ({ line: { flexDirection: "row", }, highlightedLine: { backgroundColor: theme.colors.accentBorder, }, gutter: { alignItems: "flex-end", paddingRight: theme.spacing[3], flexShrink: 0, }, gutterText: { color: theme.colors.foreground, fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: theme.fontSize.code * 1.45, opacity: 0.4, userSelect: "none", }, lineText: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: theme.fontSize.code * 1.45, flex: 1, }, })); function FilePreviewBody({ preview, isLoading, showDesktopWebScrollbar, isMobile, location, imagePreviewUri, }: FilePreviewBodyProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const filePath = location.path; const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; const previewScrollRef = useRef(null); const webScrollbarStyle = useWebScrollbarStyle(); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { enabled: showDesktopWebScrollbar, }); const highlightedLines = useMemo(() => { if (!preview || preview.kind !== "text" || isMarkdownFile) { return null; } return highlightCode(preview.content ?? "", filePath); }, [isMarkdownFile, preview, filePath]); const gutterWidth = useMemo(() => { if (!highlightedLines) return 0; return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code); }, [highlightedLines, theme.fontSize.code]); const lineHeight = theme.fontSize.code * 1.45; const lineSelection = useMemo(() => { if (!highlightedLines) { return null; } return clampLineSelection({ lineStart: location.lineStart, lineEnd: location.lineEnd, lineCount: highlightedLines.length, }); }, [highlightedLines, location.lineEnd, location.lineStart]); const imageSource = useMemo( () => (imagePreviewUri ? { uri: imagePreviewUri } : null), [imagePreviewUri], ); useEffect(() => { if (!lineSelection) { return; } const timeout = setTimeout(() => { previewScrollRef.current?.scrollTo({ y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight), animated: false, }); }, 0); return () => clearTimeout(timeout); }, [lineHeight, lineSelection]); if (isLoading && !preview) { return ( {t("panels.file.loading")} ); } if (!preview) { return ( {t("panels.file.noPreview")} ); } if (preview.kind === "text") { if (isMarkdownFile) { return ( {scrollbar.overlay} ); } const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; const keyedLines = lines.map((tokens, index) => ({ key: `line-${index}`, tokens, lineNumber: index + 1, })); const codeLines = ( {keyedLines.map(({ key, tokens, lineNumber }) => ( = (lineSelection?.lineStart ?? 0) && lineNumber <= (lineSelection?.lineEnd ?? 0) } /> ))} ); return ( {isMobile ? ( {codeLines} ) : ( {codeLines} )} {scrollbar.overlay} ); } if (preview.kind === "image") { if (!imagePreviewUri) { return ( {t("panels.file.loading")} ); } return ( {scrollbar.overlay} ); } return ( {t("panels.file.binaryPreviewUnavailable")} {formatFileSize({ size: preview.size })} ); } export function FilePane({ serverId, workspaceRoot, location, }: { serverId: string; workspaceRoot: string; location: WorkspaceFileLocation; }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); const readTarget = useMemo( () => normalizedFilePath ? resolveFilePreviewReadTarget({ path: normalizedFilePath, workspaceRoot: normalizedWorkspaceRoot, }) : null, [normalizedFilePath, normalizedWorkspaceRoot], ); // Re-read the file when this pane becomes visible again (#445). `isActive` // covers tab switches, `isAppVisible` the whole-app background/foreground; the // gate itself lives in isFileQueryEnabled. const isActive = useRetainedPanelActive(); const isAppVisible = useAppVisible(); const query = useQuery({ queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null], enabled: isFileQueryEnabled({ hasReadTarget: Boolean(client && readTarget), isTabActive: isActive, isAppVisible, }), queryFn: async () => { if (!client || !readTarget) { return { file: null as ExplorerFile | null, error: t("workspace.terminal.hostDisconnected"), }; } try { const file = await client.readFile(readTarget.cwd, readTarget.path); const preview = await createFilePanePreview(file); return { file: preview.file, imageAttachment: preview.imageAttachment, error: null, }; } catch (error) { return { file: null, imageAttachment: null, error: error instanceof Error ? error.message : t("panels.file.failedToLoad"), }; } }, staleTime: 5_000, refetchOnMount: true, }); const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null); return ( {query.data?.error ? ( {query.data.error} ) : null} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0, }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", padding: theme.spacing[4], }, loadingText: { marginTop: theme.spacing[2], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, textAlign: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, binaryMetaText: { marginTop: theme.spacing[2], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, previewScrollContainer: { flex: 1, minHeight: 0, }, previewContent: { flex: 1, minHeight: 0, }, previewCodeScrollContent: { padding: theme.spacing[4], }, previewMarkdownScrollContent: { padding: theme.spacing[4], }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], alignItems: "center", justifyContent: "center", }, previewImage: { width: "100%", height: 420, }, }));