From c70a6cfb9ea29c87df52b6b6c574775b0d3b169c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:56:12 +0200 Subject: [PATCH] fix(app): resolve Markdown preview links from their document (#7538) Preview links used the workspace root even when the document was nested. Carry a separate relative-path base through the file-link resolver so clicks and hover paths agree while chat keeps workspace-relative resolution. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/markdown-relative-links.spec.ts | 47 +++++++++++++++++++ .../app/src/assistant-file-links/parse.ts | 30 ++++++++++++ .../app/src/assistant-file-links/provider.tsx | 12 ++++- .../src/assistant-file-links/resolver.test.ts | 44 +++++++++++++++++ .../app/src/assistant-file-links/resolver.ts | 2 + .../src/assistant-file-links/use-file-link.ts | 18 ++++--- .../file-panels/markdown-preview/index.tsx | 26 +++++++--- 7 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 packages/app/e2e/browser/markdown-relative-links.spec.ts diff --git a/packages/app/e2e/browser/markdown-relative-links.spec.ts b/packages/app/e2e/browser/markdown-relative-links.spec.ts new file mode 100644 index 0000000000..0d84d00390 --- /dev/null +++ b/packages/app/e2e/browser/markdown-relative-links.spec.ts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { + expandFolder, + openFileExplorer, + openFileFromExplorer, +} from "../support/helpers/file-explorer"; + +async function openNestedGuide(page: Page): Promise { + await openFileExplorer(page); + await expandFolder(page, "docs"); + await openFileFromExplorer(page, "guide.md"); +} + +async function followDetailsLink(page: Page): Promise { + await page + .getByTestId("markdown-preview-frame") + .getByRole("link", { name: "Details", exact: true }) + .click(); +} + +test("Markdown preview resolves a relative link from the document directory", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "markdown-relative-links-" }); + await mkdir(path.join(workspace.repoPath, "docs/guide"), { recursive: true }); + await writeFile( + path.join(workspace.repoPath, "docs/guide.md"), + "# Guide\n\n[Details](guide/details.md)\n", + ); + await writeFile( + path.join(workspace.repoPath, "docs/guide/details.md"), + "# Nested details destination\n", + ); + await workspace.navigateTo(); + + await openNestedGuide(page); + await followDetailsLink(page); + + await expect( + page + .getByTestId("markdown-preview-frame") + .getByText("Nested details destination", { exact: true }), + ).toBeVisible(); +}); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 4fb41a72ca..8e02310fea 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0 const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i; export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** Authored document links resolve from this directory, independently of the workspace. */ + relativePathBase?: string; /** Authored links identify a file; only inferred text needs filename heuristics. */ explicitLink?: boolean; /** @@ -281,6 +283,7 @@ export function parseAssistantFileLink( workspaceRoot: options.workspaceRoot, decodeHref: options.decodeHref, explicitLink: options.explicitLink, + relativePathBase: options.relativePathBase, }); if (relativeTarget) { return relativeTarget; @@ -395,6 +398,15 @@ function parseWorkspaceRelativeFileLink( }; } + const documentBase = options.explicitLink && normalizePathInput(options.relativePathBase); + if (documentBase) { + return { + raw: value, + path: resolveDocumentRelativePath(parsed.path, documentBase), + ...parsed.lines, + }; + } + const workspaceRoot = normalizePathInput(options.workspaceRoot); if (!workspaceRoot) { return null; @@ -638,6 +650,24 @@ function isDomainLikePathSegment(segment: string): boolean { return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment); } +function resolveDocumentRelativePath(relativePath: string, baseDirectory: string): string { + const prefix = baseDirectory.startsWith("/") ? "/" : ""; + const segments = baseDirectory.split("/").filter(Boolean); + for (const segment of relativePath.split("/")) { + const last = segments.at(-1) ?? ""; + if (!segment || segment === ".") continue; + if (segment !== "..") { + segments.push(segment); + } else if (last && last !== "~" && last !== ".." && !/^[A-Za-z]:$/.test(last)) { + segments.pop(); + } else if (!prefix && !/^[A-Za-z]:$/.test(last)) { + segments.push(".."); + } + } + const resolved = prefix + segments.join("/"); + return /^[A-Za-z]:$/.test(resolved) ? `${resolved}/` : resolved; +} + function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null { const normalizedPath = normalizePathToken(pathValue); if (!normalizedPath || isAbsolutePath(normalizedPath)) { diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index 967e1c32e3..a8773a2398 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -22,6 +22,7 @@ export interface AssistantFileLinkResolverConfig { client?: AssistantFileLinkDaemonClient | null; serverId?: string; workspaceRoot?: string; + relativePathBase?: string; onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; toast?: ToastApi | null; } @@ -42,6 +43,7 @@ export function AssistantFileLinkResolverProvider({ client, serverId, workspaceRoot, + relativePathBase, onOpenWorkspaceFile, toast, children, @@ -50,10 +52,18 @@ export function AssistantFileLinkResolverProvider({ client, serverId, workspaceRoot, + relativePathBase, onOpenWorkspaceFile, toast, }); - configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast }; + configRef.current = { + client, + serverId, + workspaceRoot, + relativePathBase, + onOpenWorkspaceFile, + toast, + }; const getDirectorySuggestions = useCallback(async (input) => { const activeClient = configRef.current.client; diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 96884c21c7..121c60f5a0 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -18,6 +18,50 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it.each([ + ["guide/details.md", "/repo/docs/guide/details.md"], + ["../README.md", "/repo/README.md"], + ["guide/details%20page.md", "/repo/docs/guide/details page.md"], +])("resolves authored href %s from its document directory", (href, expected) => { + expect( + classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }), + ).toMatchObject({ kind: "resolved", value: { kind: "file", target: { path: expected } } }); +}); + +it.each([ + ["C:/repo/docs", "../README.md", "C:/repo/README.md"], + ["C:/repo/docs", "../../", "C:/"], + ["~/.paseo/docs", "../guide.md", "~/.paseo/guide.md"], + ["/repo/docs", "/other/file.md", "/other/file.md"], + ["/repo/docs", "file:///other/file.md", "/other/file.md"], + ["/repo/docs", "guide/details.md#L3", "/repo/docs/guide/details.md"], +])( + "resolves %s with href %s without changing absolute destinations", + (relativePathBase, href, expected) => { + expect( + classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase }), + ).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: expected } }, + }); + }, +); + +it("keeps inferred paths and chat links rooted at the workspace", () => { + const context = { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }; + expect(classifyForResolution({ href: "src/file.ts", markup: "linkify" }, context)).toMatchObject({ + value: { kind: "file", target: { path: "/repo/src/file.ts" } }, + }); + expect( + classifyForResolution({ href: "guide/details.md" }, { workspaceRoot: "/repo" }), + ).toMatchObject({ + value: { kind: "file", target: { path: "/repo/guide/details.md" } }, + }); + expect(classifyForResolution({ href: "https://example.com/docs" }, context)).toMatchObject({ + value: { kind: "external", url: "https://example.com/docs" }, + }); +}); + it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])( "resolves an explicit extensionless folder href: %s", (href) => { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index be012077a3..66c4137e2d 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -17,6 +17,7 @@ export interface AssistantFileLinkSource { export interface AssistantFileLinkContext { workspaceRoot?: string; + relativePathBase?: string; } export interface DirectorySuggestionEntry { @@ -118,6 +119,7 @@ export function classifyForResolution( workspaceRoot: context.workspaceRoot, decodeHref: tokenInfo.fromHref, explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source), + relativePathBase: context.relativePathBase, }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 4f117e5dc1..344727c571 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -15,6 +15,7 @@ import { fetchDaemonResolution, type AssistantFileLinkResolution, type AssistantFileLinkSource, + type AssistantFileLinkContext, } from "./resolver"; export interface UseFileLinkResult { @@ -54,13 +55,15 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult const stableSource = useStableSource(source); const activeConfig = context.configRef.current; const workspaceRoot = activeConfig.workspaceRoot; + const relativePathBase = activeConfig.relativePathBase; const serverId = activeConfig.serverId; const resolution = useMemo( () => classifyForResolution(stableSource, { workspaceRoot, + relativePathBase, }), - [stableSource, workspaceRoot], + [stableSource, workspaceRoot, relativePathBase], ); const queryKey = useMemo( () => @@ -154,12 +157,12 @@ export function useAssistantFileLinkActions(): AssistantFileLinkActions { ); const canOpen = useCallback( (source: AssistantFileLinkSource) => - canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot), + canOpenAssistantFileLink(source, context.configRef.current), [context.configRef], ); const canResolveFile = useCallback( (source: AssistantFileLinkSource) => - canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot), + canResolveAssistantFileLinkToFile(source, context.configRef.current), [context.configRef], ); @@ -176,6 +179,7 @@ function openAssistantFileLink(input: { const capturedConfig = input.context.configRef.current; const capturedResolution = classifyForResolution(input.source, { workspaceRoot: capturedConfig.workspaceRoot, + relativePathBase: capturedConfig.relativePathBase, }); if (capturedResolution.kind === "resolved") { @@ -233,17 +237,17 @@ function openAssistantFileLink(input: { function canOpenAssistantFileLink( source: AssistantFileLinkSource, - workspaceRoot: string | undefined, + context: AssistantFileLinkContext, ): boolean { - const resolution = classifyForResolution(source, { workspaceRoot }); + const resolution = classifyForResolution(source, context); return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored"; } function canResolveAssistantFileLinkToFile( source: AssistantFileLinkSource, - workspaceRoot: string | undefined, + context: AssistantFileLinkContext, ): boolean { - const resolution = classifyForResolution(source, { workspaceRoot }); + const resolution = classifyForResolution(source, context); return resolution.kind === "needsLookup" || resolution.value.kind === "file"; } diff --git a/packages/app/src/file-panels/markdown-preview/index.tsx b/packages/app/src/file-panels/markdown-preview/index.tsx index 8a45b5c296..4a531408ba 100644 --- a/packages/app/src/file-panels/markdown-preview/index.tsx +++ b/packages/app/src/file-panels/markdown-preview/index.tsx @@ -1,4 +1,9 @@ import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { resolveMarkdownFileDirectory } from "@/components/markdown/image-source"; +import { + AssistantFileLinkResolverProvider, + useAssistantFileLinkEnvironment, +} from "@/assistant-file-links"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -19,6 +24,8 @@ export function FileMarkdownPreview({ serverId?: string; }) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const environment = useAssistantFileLinkEnvironment(); + const relativePathBase = resolveMarkdownFileDirectory({ markdownPath: filePath, workspaceRoot }); return ( @@ -44,13 +51,18 @@ export function FileMarkdownPreview({ ))} ) : null} - + + + );