mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user