From 4c730611e680388ebcc3ee8f5f3ddb90d19bf444 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 23:08:47 +0700 Subject: [PATCH] Fix markdown code rendering in file previews --- packages/app/src/components/file-pane.tsx | 316 +++++++++++++++++++++- 1 file changed, 311 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 315010177..6d70fdb4d 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,15 +1,26 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import Markdown, { MarkdownIt } from "react-native-markdown-display"; +import Markdown, { + type ASTNode, + MarkdownIt, + type RenderRules, +} from "react-native-markdown-display"; import { ActivityIndicator, Image as RNImage, + Linking, ScrollView as RNScrollView, Text, + type TextProps, + type TextStyle, View, + type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; @@ -22,6 +33,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; import { createMarkdownStyles } from "@/styles/markdown-styles"; +import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; @@ -46,6 +58,8 @@ interface FilePreviewBodyProps { imagePreviewUri: string | null; } +type MarkdownStyles = Record; + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -116,6 +130,295 @@ function clampLineSelection(input: { return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; } +interface MarkdownInheritedTextProps { + inheritedStyles: TextStyle; + textStyle: TextStyle; + style?: TextStyle; + monoSurface?: boolean; + onPress?: TextProps["onPress"]; + accessibilityRole?: TextProps["accessibilityRole"]; + children: ReactNode; +} + +function MarkdownInheritedText({ + inheritedStyles, + textStyle, + style: overrideStyle, + monoSurface, + onPress, + accessibilityRole, + children, +}: MarkdownInheritedTextProps) { + const style = useMemo( + () => [inheritedStyles, textStyle, overrideStyle], + [inheritedStyles, textStyle, overrideStyle], + ); + return ( + + {children} + + ); +} + +interface MarkdownListItemContentProps { + contentStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 }; +const EMPTY_TEXT_STYLE: TextStyle = {}; + +function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) { + const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]); + return {children}; +} + +interface MarkdownListViewProps { + baseStyle: ViewStyle; + spacing: { marginTop: number; marginBottom: number }; + children: ReactNode; +} + +function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) { + const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]); + return {children}; +} + +interface FilePreviewMarkdownLinkProps { + href: string; + inheritedStyles: TextStyle; + linkStyle: TextStyle; + onLinkPress?: (url: string) => boolean; + children: ReactNode; +} + +function FilePreviewMarkdownLink({ + href, + inheritedStyles, + linkStyle, + onLinkPress, + children, +}: FilePreviewMarkdownLinkProps) { + const handlePress = useCallback(() => { + if (!href) return; + if (onLinkPress?.(href) === false) return; + void Linking.openURL(href); + }, [href, onLinkPress]); + + return ( + + {children} + + ); +} + +function getMarkdownLinkHref(node: ASTNode): string { + const href = node.attributes?.href; + return typeof href === "string" ? href : ""; +} + +function createFilePreviewMarkdownRules(): RenderRules { + return { + text: ( + node: ASTNode, + _children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {node.content} + + ), + textgroup: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {children} + + ), + strong: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {children} + + ), + em: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {children} + + ), + s: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {children} + + ), + hardbreak: (node: ASTNode) => {"\n"}, + softbreak: (node: ASTNode) => {"\n"}, + code_block: ( + node: ASTNode, + _children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + ), + fence: ( + node: ASTNode, + _children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + ), + code_inline: ( + node: ASTNode, + _children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, + ) => ( + + {node.content ?? ""} + + ), + bullet_list: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownStyles, + ) => ( + + {children} + + ), + ordered_list: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownStyles, + ) => ( + + {children} + + ), + list_item: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownStyles, + ) => { + const { isOrdered, marker } = getMarkdownListMarker(node, parent); + const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; + const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; + + return ( + + {marker} + {children} + + ); + }, + paragraph: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + ) => ( + + {children} + + ), + link: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + onLinkPress?: (url: string) => boolean, + ) => ( + + {children} + + ), + }; +} + const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, @@ -198,6 +501,7 @@ function FilePreviewBody({ const filePath = location.path; const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); + const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []); const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; @@ -280,9 +584,11 @@ function FilePreviewBody({ scrollEventThrottle={16} showsVerticalScrollIndicator={!showDesktopWebScrollbar} > - - {preview.content ?? ""} - + + + {preview.content ?? ""} + + {scrollbar.overlay}