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>
This commit is contained in:
paseo-bot[bot]
2026-10-21 06:56:12 +02:00
committed by GitHub
parent 9bce7dbd25
commit c70a6cfb9e
7 changed files with 164 additions and 15 deletions
@@ -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<void> {
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
}
async function followDetailsLink(page: Page): Promise<void> {
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();
});
@@ -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)) {
@@ -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<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
@@ -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) => {
@@ -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" } };
@@ -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";
}
@@ -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 (
<View style={styles.outerGutter}>
@@ -44,13 +51,18 @@ export function FileMarkdownPreview({
))}
</View>
) : null}
<MarkdownRenderer
text={document.body}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
<AssistantFileLinkResolverProvider
{...environment}
relativePathBase={relativePathBase ?? undefined}
>
<MarkdownRenderer
text={document.body}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</AssistantFileLinkResolverProvider>
</View>
</View>
);