From 9eaf7ad0c1be74995b07dc82c81d9a010e932418 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:51:07 +0200 Subject: [PATCH] Render local images in Markdown file previews (#6750) * fix(app): render local relative images in markdown file preview File preview dropped relative images because it only accepted http/data URIs and never resolved paths against the opened markdown file. * test(app): cover local relative images in markdown preview e2e * fix(app): show local images in Markdown file previews Carry forward document-relative image loading and preserve the host home directory when resolving images beside home-relative Markdown files. Decode file URIs once, and require decoded images in the file-pane regression. Co-authored-by: Ouyang Xingyuan * fix(app): resolve local Markdown image URL suffixes --------- Co-authored-by: Ouyang Xingyuan Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/file-editing.spec.ts | 43 +++++ .../markdown-preview/image-source.test.ts | 165 ++++++++++++++++++ .../markdown-preview/image-source.ts | 150 ++++++++++++++++ .../src/file-pane/markdown-preview/index.tsx | 54 +++++- .../markdown-preview/preview-image.tsx | 151 ++++++++++++++++ packages/app/src/file-pane/pane.tsx | 29 ++- 6 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/file-pane/markdown-preview/image-source.test.ts create mode 100644 packages/app/src/file-pane/markdown-preview/image-source.ts create mode 100644 packages/app/src/file-pane/markdown-preview/preview-image.tsx diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index c0a20dc6d0..7726c581f3 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { + expandFolder, openFileExplorer, openFileFromExplorer, expectFileTabOpen, @@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { + const image = page.getByRole("img", { name, exact: true }); + await expect(image).toBeVisible(); + await expect + .poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth)) + .toBeGreaterThan(0); +} + function watchRequestsTo(page: Page, origin: string): string[] { const requests: string[] = []; page.on("request", (request) => { @@ -684,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => { await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource); }); + test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { + test.setTimeout(90_000); + const workspace = await withWorkspace({ prefix: "file-md-relative-image-" }); + await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true }); + await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true }); + await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL); + await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL); + await writeFile( + path.join(workspace.repoPath, "docs", "guide.md"), + [ + "# Guide", + "", + "![sibling](./sibling.png)", + "", + "![hero](../data/cache/hero.png?v=2#preview)", + "", + ].join("\n"), + "utf8", + ); + await workspace.navigateTo(); + await openFileExplorer(page); + await expandFolder(page, "docs"); + await openFileFromExplorer(page, "guide.md"); + await expectFileTabOpen(page, "docs/guide.md"); + + const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible(); + await expectMarkdownImageLoaded(page, "sibling"); + await expectMarkdownImageLoaded(page, "hero"); + await visibleFilePane.screenshot({ + path: test.info().outputPath("markdown-relative-images.png"), + }); + }); + test("previews and refreshes an HTML plan while preserving source access", async ({ page, withWorkspace, diff --git a/packages/app/src/file-pane/markdown-preview/image-source.test.ts b/packages/app/src/file-pane/markdown-preview/image-source.test.ts new file mode 100644 index 0000000000..8277e57492 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source"; + +describe("resolveMarkdownFileDirectory", () => { + it("uses the parent of an absolute markdown path", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "/Users/test/project/docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs"); + }); + + it("joins a workspace-relative markdown path to the workspace root", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "docs/星谷云/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/星谷云"); + }); + + it("preserves the host home directory for home-relative markdown paths", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans"); + }); +}); + +describe("resolveMarkdownPreviewImageSource", () => { + it("passes through remote and data URIs", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "https://example.com/image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("https://example.com/image.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "data:image/png;base64,abc", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("resolves a sibling image against the markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/images/hero.png"); + }); + + it("ignores local URL query strings and fragments", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/images/hero.png"); + }); + + it("preserves encoded filename characters when stripping URL suffixes", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./hero%23draft%3Fv.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/hero#draft?v.png"); + }); + + it("preserves remote URL query strings and fragments", () => { + const source = "https://example.com/hero.png?v=2#preview"; + expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe( + source, + ); + }); + + it("resolves parent-relative images that stay inside the workspace", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../../data/cache/xinggu_docs/12-linkedin/token.png", + markdownPath: "docs/星谷云/12-LinkedIn.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png"); + }); + + it("keeps absolute and home-relative image paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "/tmp/screenshot.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/screenshot.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "~/.paseo/screenshots/output.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/screenshots/output.png"); + }); + + it("normalizes file URIs into host paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/paseo-preview.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/paseo-preview.png"); + }); + + it("resolves images beside a home-relative document without using the workspace root", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "screenshots/output.png", + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans/screenshots/output.png"); + }); + + it("decodes a file URI exactly once", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/hero%2520image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/tmp/hero%20image.png"); + }); + + it("resolves parent segments from a home-relative document", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../images/hero.png", + markdownPath: "~/docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("~/images/hero.png"); + }); + + it("resolves Windows relative images against a drive-rooted markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "..\\images\\hero.png", + markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md", + workspaceRoot: "C:\\Users\\test\\repo", + }), + ).toBe("C:/Users/test/repo/images/hero.png"); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/image-source.ts b/packages/app/src/file-pane/markdown-preview/image-source.ts new file mode 100644 index 0000000000..5316d94279 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.ts @@ -0,0 +1,150 @@ +import { localFileSourceToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizePathSegments(value: string): string { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0) { + segments.pop(); + } + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (isHomeRelativePath(normalizedInput)) { + return `~/${normalizePathSegments(normalizedInput.slice(2))}`; + } + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + return trimTrailingSeparators( + `${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`, + ); +} + +function parentDirectory(path: string): string | null { + const normalized = trimTrailingSeparators(path.replace(/\\/g, "/")); + if (!normalized) { + return null; + } + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) { + return normalized.endsWith("/") ? normalized : `${normalized}/`; + } + + const index = normalized.lastIndexOf("/"); + if (index < 0) { + return null; + } + if (index === 0) { + return "/"; + } + const parent = normalized.slice(0, index); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +} + +function joinAbsolute(baseDirectory: string, relativePath: string): string | null { + const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/")); + const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (!directory) { + return null; + } + return normalizeAbsolutePath(`${directory}/${relative}`); +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +function decodeRelativeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} + +export function resolveMarkdownFileDirectory(input: { + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const markdownPath = input.markdownPath.trim().replace(/\\/g, "/"); + if (!markdownPath) { + return null; + } + + if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) { + const absolute = normalizeAbsolutePath(markdownPath); + return absolute ? parentDirectory(absolute) : null; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + + const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath); + return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null; +} + +export function resolveMarkdownPreviewImageSource(input: { + source: string; + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } + + const pathSource = source.replace(/[?#].*$/, ""); + const path = localFileSourceToPath( + pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource), + ); + if (!path) { + return null; + } + + if (isHomeRelativePath(path) || isAbsolutePath(path)) { + return path; + } + + const directory = resolveMarkdownFileDirectory({ + markdownPath: input.markdownPath, + workspaceRoot: input.workspaceRoot, + }); + if (!directory) { + return path; + } + + return joinAbsolute(directory, path); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index e8d172c61b..a79705323f 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -1,11 +1,59 @@ import { useMemo } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ASTNode, RenderRules } from "react-native-markdown-display"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { + createSharedMarkdownRules, + MarkdownRenderer, + type MarkdownStyles, +} from "@/components/markdown/renderer"; import { parseMarkdownPreviewDocument } from "./document"; +import { resolveMarkdownPreviewImageSource } from "./image-source"; +import { FileMarkdownPreviewImage } from "./preview-image"; -export function FileMarkdownPreview({ source }: { source: string }) { +export function FileMarkdownPreview({ + source, + filePath, + workspaceRoot, + client, + serverId, +}: { + source: string; + filePath: string; + workspaceRoot?: string; + client?: DaemonClient | null; + serverId?: string; +}) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const rules = useMemo( + () => ({ + ...createSharedMarkdownRules(), + image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => { + const resolved = resolveMarkdownPreviewImageSource({ + source: String(node.attributes?.src ?? ""), + markdownPath: filePath, + workspaceRoot, + }); + if (!resolved) { + return null; + } + + return ( + + ); + }, + }), + [client, filePath, serverId, workspaceRoot], + ); return ( @@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { ))} ) : null} - + ); diff --git a/packages/app/src/file-pane/markdown-preview/preview-image.tsx b/packages/app/src/file-pane/markdown-preview/preview-image.tsx new file mode 100644 index 0000000000..ef27c14978 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/preview-image.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useTranslation } from "react-i18next"; +import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const PREVIEW_IMAGE_MIN_HEIGHT = 160; + +export function FileMarkdownPreviewImage({ + source, + occurrenceKey, + alt, + client, + workspaceRoot, + serverId, +}: { + source: string; + occurrenceKey: string; + alt?: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, + }); + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: PREVIEW_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [styles.surface, imageSizeStyle], + [imageSizeStyle], + ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); + + if (image.status === "failed") { + return ( + + {image.message} + + ); + } + + if (!binding) { + return ( + + + + ); + } + + return ( + + + + + {image.status === "loading" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + frame: { + width: "100%", + marginBottom: theme.spacing[4], + }, + surface: { + width: "100%", + overflow: "hidden", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + image: { + width: "100%", + height: "100%", + }, + state: { + minHeight: PREVIEW_IMAGE_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + loadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..ea3c5b1edb 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -54,6 +54,9 @@ interface FilePreviewBodyProps { location: WorkspaceFileLocation; navigationRevision: number; imagePreviewUri: string | null; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; } type TextExplorerFile = ExplorerFile & { kind: "text" }; @@ -135,6 +138,9 @@ function FilePreviewBody({ location, navigationRevision, imagePreviewUri, + workspaceRoot, + client, + serverId, }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -182,7 +188,13 @@ function FilePreviewBody({ style={styles.previewContent} showsVerticalScrollIndicator > - + ); @@ -297,6 +309,7 @@ export function FilePane({ return ( ); } function EditableFilePane({ + serverId, + workspaceRoot, client, cwd, path, @@ -470,6 +492,8 @@ function EditableFilePane({ location, navigationRevision, }: { + serverId: string; + workspaceRoot: string; client: DaemonClient; cwd: string; path: string; @@ -622,6 +646,9 @@ function EditableFilePane({ location={location} navigationRevision={navigationRevision} imagePreviewUri={null} + workspaceRoot={workspaceRoot} + client={client} + serverId={serverId} /> )}