diff --git a/frontend/app/element/markdown-link.test.tsx b/frontend/app/element/markdown-link.test.tsx new file mode 100644 index 0000000000..d96180c9e8 --- /dev/null +++ b/frontend/app/element/markdown-link.test.tsx @@ -0,0 +1,202 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { openLink } from "@/app/store/global"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import type React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownLink } from "./markdown-link"; + +vi.mock("@/app/store/global", () => ({ openLink: vi.fn() })); +vi.mock("@/app/store/wshclientapi", () => ({ RpcApi: { FileJoinCommand: vi.fn() } })); +vi.mock("@/app/store/wshrpcutil", () => ({ TabRpcClient: {} })); + +const localOpts: MarkdownResolveOpts = { connName: null, baseDir: "C:/docs/examples" }; + +function makeLink(href: string, resolveOpts = localOpts, onOpenFile = vi.fn(async (_path: string) => {})) { + const setFocusedHeading = vi.fn(); + const link = MarkdownLink({ + props: { href, children: "Example" }, + resolveOpts, + onOpenFile, + setFocusedHeading, + }); + const event = { preventDefault: vi.fn() } as unknown as React.MouseEvent; + return { + link, + onOpenFile, + setFocusedHeading, + event, + click: () => link.props.onClick(event), + }; +} + +describe("Markdown file links", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: "C:/docs/examples/target" } as FileInfo); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { connName: null, baseDir: "C:/docs/examples", baseUri: "wsh://local/C:/docs/examples" }, + { connName: "local", baseDir: "/docs/examples", baseUri: "wsh://local//docs/examples" }, + { connName: "example-host", baseDir: "/docs/examples", baseUri: "wsh://example-host//docs/examples" }, + { connName: "wsl://Ubuntu", baseDir: "/docs/examples", baseUri: "wsh://wsl://Ubuntu//docs/examples" }, + ])( + "resolves MD/PDF/WAV links on $connName using the document directory", + async ({ connName, baseDir, baseUri }) => { + for (const href of ["chapter.md", "diagram.pdf", "sample.wav"]) { + vi.mocked(RpcApi.FileJoinCommand).mockClear(); + const resolvedPath = `${baseDir}/${href}`; + vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: resolvedPath } as FileInfo); + const { click, event, onOpenFile, setFocusedHeading } = makeLink(href, { connName, baseDir }); + await click(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [baseUri, href]); + expect(onOpenFile).toHaveBeenCalledExactlyOnceWith(resolvedPath); + expect(openLink).not.toHaveBeenCalled(); + expect(setFocusedHeading).not.toHaveBeenCalled(); + } + } + ); + + it.each([ + ["../Other%20Notes.md", "../Other Notes.md"], + ["./subfolder/chapter.md", "./subfolder/chapter.md"], + ["../../chapter.md", "../../chapter.md"], + ["folder/", "folder/"], + ["/docs/chapter.md", "/docs/chapter.md"], + ["~/chapter.md", "~/chapter.md"], + ["notes%23draft%3F.md", "notes#draft?.md"], + ["100%25.md", "100%.md"], + ["literal%2520space.md", "literal%20space.md"], + ["%E6%97%A5%E8%A8%98.md", "日記.md"], + ["chapter.md#section", "chapter.md"], + ["sample.wav?download=1#t=2", "sample.wav"], + ])("passes the decoded path for %s to the connection-aware backend", async (href, path) => { + const { click, onOpenFile } = makeLink(href); + await click(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [ + "wsh://local/C:/docs/examples", + path, + ]); + expect(onOpenFile).toHaveBeenCalledExactlyOnceWith("C:/docs/examples/target"); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("preserves same-document heading navigation", async () => { + const { click, setFocusedHeading, onOpenFile } = makeLink("#section"); + await click(); + expect(setFocusedHeading).toHaveBeenCalledExactlyOnceWith("#section"); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it.each([ + "https://example.com/chapter.md#section", + "HTTP://example.com/", + "mailto:reader@example.com", + "tel:+123456789", + "file:///C:/docs/chapter.md", + "wsh://example-host/docs/chapter.md", + "custom+scheme://example/path", + "//example.com/chapter.md", + ])("preserves existing external dispatch for %s", async (href) => { + const { click, onOpenFile, setFocusedHeading } = makeLink(href); + await click(); + expect(openLink).toHaveBeenCalledExactlyOnceWith(href); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(setFocusedHeading).not.toHaveBeenCalled(); + }); + + it.each([null, undefined, ""])("does not dispatch missing/empty href %s", async (href) => { + const { click, event, onOpenFile } = makeLink(href); + await click(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(openLink).not.toHaveBeenCalled(); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + }); + + it("leaves Markdown outside file previews on its existing route", async () => { + const { click, onOpenFile } = makeLink("chapter.md", null); + await click(); + expect(openLink).toHaveBeenCalledExactlyOnceWith("chapter.md"); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + }); + + it("requires a preview navigation callback before resolving paths", async () => { + const link = MarkdownLink({ + props: { href: "chapter.md" }, + resolveOpts: localOpts, + setFocusedHeading: vi.fn(), + }); + await link.props.onClick({ preventDefault: vi.fn() } as unknown as React.MouseEvent); + expect(openLink).toHaveBeenCalledExactlyOnceWith("chapter.md"); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + }); + + it.each(["bad%ZZ.md", "incomplete%.md"])("contains malformed encoding in %s without OS fallback", async (href) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { click, onOpenFile } = makeLink(href); + await click(); + expect(warn).toHaveBeenCalledOnce(); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("does not treat a query-only destination as a file", async () => { + const { click, onOpenFile } = makeLink("?download=1"); + await click(); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("contains backend resolution errors without external fallback", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new Error("connection unavailable"); + vi.mocked(RpcApi.FileJoinCommand).mockRejectedValue(error); + const { click, onOpenFile } = makeLink("chapter.md"); + await click(); + expect(warn).toHaveBeenCalledExactlyOnceWith("Failed to open Markdown file link:", "chapter.md", error); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("awaits and contains preview navigation errors", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new Error("navigation unavailable"); + const onOpenFile = vi.fn(async (_path: string) => { + throw error; + }); + const { click } = makeLink("chapter.md", localOpts, onOpenFile); + await click(); + expect(onOpenFile).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledExactlyOnceWith("Failed to open Markdown file link:", "chapter.md", error); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("retains the original href and escapes link text in the rendered anchor", () => { + const markup = renderToStaticMarkup( + Example" }} + setFocusedHeading={vi.fn()} + /> + ); + expect(markup).toContain('href="../Other%20Notes.md"'); + expect(markup).toContain("<script>Example</script>"); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/element/markdown-link.tsx b/frontend/app/element/markdown-link.tsx new file mode 100644 index 0000000000..c01d7bcf71 --- /dev/null +++ b/frontend/app/element/markdown-link.tsx @@ -0,0 +1,51 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { formatRemoteUri } from "@/util/waveutil"; +import type React from "react"; +import { openLink } from "../store/global"; + +export const MarkdownLink = ({ + setFocusedHeading, + props, + resolveOpts, + onOpenFile, +}: { + props: React.AnchorHTMLAttributes; + setFocusedHeading: (href: string) => void; + resolveOpts?: MarkdownResolveOpts; + onOpenFile?: (path: string) => Promise; +}) => { + const onClick = async (e: React.MouseEvent) => { + e.preventDefault(); + const href = props.href; + if (!href) { + return; + } + if (href.startsWith("#")) { + setFocusedHeading(href); + } else if (resolveOpts && onOpenFile && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) { + try { + // Remove URL suffixes before decoding, so escaped # and ? remain part of the filename. + const path = decodeURIComponent(href.split(/[?#]/, 1)[0]); + if (!path) { + return; + } + const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName); + const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, path]); + await onOpenFile(fileInfo.path); + } catch (err) { + console.warn("Failed to open Markdown file link:", href, err); + } + } else { + openLink(href); + } + }; + return ( + + {props.children} + + ); +}; diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 5ecc252876..36a2608684 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -3,6 +3,7 @@ import { CopyButton } from "@/app/element/copybutton"; import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin"; +import { MarkdownLink } from "@/app/element/markdown-link"; import { MarkdownContentBlockType, resolveRemoteFile, @@ -22,7 +23,6 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeSlug from "rehype-slug"; import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; import remarkGfm from "remark-gfm"; -import { openLink } from "../store/global"; import { IconButton } from "./iconbutton"; import "./markdown.scss"; @@ -38,28 +38,6 @@ const initializeMermaid = async () => { } }; -const Link = ({ - setFocusedHeading, - props, -}: { - props: React.AnchorHTMLAttributes; - setFocusedHeading: (href: string) => void; -}) => { - const onClick = (e: React.MouseEvent) => { - e.preventDefault(); - if (props.href.startsWith("#")) { - setFocusedHeading(props.href); - } else { - openLink(props.href); - } - }; - return ( - - {props.children} - - ); -}; - const Heading = ({ props, hnum }: { props: React.HTMLAttributes; hnum: number }) => { return (
@@ -300,6 +278,7 @@ type MarkdownProps = { contentClassName?: string; onClickExecute?: (cmd: string) => void; resolveOpts?: MarkdownResolveOpts; + onOpenFile?: (path: string) => Promise; scrollable?: boolean; rehype?: boolean; fontSizeOverride?: number; @@ -314,6 +293,7 @@ const Markdown = ({ className, contentClassName, resolveOpts, + onOpenFile, fontSizeOverride, fixedFontSizeOverride, scrollable = true, @@ -349,7 +329,12 @@ const Markdown = ({ const markdownComponents: Partial = { a: (props: React.HTMLAttributes) => ( - + ), p: (props: React.HTMLAttributes) =>
, h1: (props: React.HTMLAttributes) => , diff --git a/frontend/app/view/preview/preview-markdown.test.tsx b/frontend/app/view/preview/preview-markdown.test.tsx new file mode 100644 index 0000000000..f9d1162877 --- /dev/null +++ b/frontend/app/view/preview/preview-markdown.test.tsx @@ -0,0 +1,146 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { openLink } from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atom } from "jotai"; +import type React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownPreview } from "./preview-markdown"; + +const { renderedLinks } = vi.hoisted(() => ({ renderedLinks: [] as React.ReactElement[] })); + +vi.mock("@/app/store/global", () => ({ + openLink: vi.fn(), + getOverrideConfigAtom: () => atom(null), +})); +vi.mock("@/app/store/wshclientapi", () => ({ RpcApi: { FileJoinCommand: vi.fn() } })); +vi.mock("@/app/store/wshrpcutil", () => ({ TabRpcClient: {} })); +vi.mock("overlayscrollbars-react", () => ({ + OverlayScrollbarsComponent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/app/element/markdown-link", async (importOriginal) => { + const actual = await importOriginal(); + return { + MarkdownLink: (props: Parameters[0]) => { + const anchor = actual.MarkdownLink(props); + renderedLinks.push(anchor); + return anchor; + }, + }; +}); + +function renderPreview(text: string, connName: string = null, filePath = "C:/docs/start.md", dir = "C:/docs") { + const connection = atom(connName); + const model = { + blockId: "markdown-preview-test", + // Supply the already-resolved connection value for server rendering. + connection, + connectionImmediate: connection, + metaFilePath: atom(filePath), + statFile: atom({ path: filePath, dir } as FileInfo), + fileContent: atom(text), + markdownShowToc: atom(false), + refreshVersion: atom(0), + goHistory: vi.fn(async (_path: string) => {}), + }; + const markup = renderToStaticMarkup(); + expect(renderedLinks).toHaveLength(1); + const anchor = renderedLinks[0]; + const event = { preventDefault: vi.fn() } as unknown as React.MouseEvent; + return { model, markup, anchor, event, click: () => anchor.props.onClick(event) }; +} + +describe("Markdown preview link wiring", () => { + beforeEach(() => { + renderedLinks.length = 0; + vi.clearAllMocks(); + vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: "C:/docs/target.md" } as FileInfo); + }); + + it("navigates a parsed file link using the displayed document directory", async () => { + const { click, event, model } = renderPreview("[Target](target.md)"); + await click(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [ + "wsh://local/C:/docs", + "target.md", + ]); + expect(model.goHistory).toHaveBeenCalledExactlyOnceWith("C:/docs/target.md"); + expect(globalStore.get(model.connection)).toBeNull(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("keeps remote file navigation on the document connection", async () => { + vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: "/docs/target.md" } as FileInfo); + const { click, model } = renderPreview("[Target](target.md)", "example-host", "/docs/start.md", "/docs"); + await click(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [ + "wsh://example-host//docs", + "target.md", + ]); + expect(model.goHistory).toHaveBeenCalledExactlyOnceWith("/docs/target.md"); + expect(globalStore.get(model.connection)).toBe("example-host"); + expect(openLink).not.toHaveBeenCalled(); + }); + + it.each([ + ["../Other%20Notes.md", "../Other Notes.md"], + ["notes%23draft%3F.md", "notes#draft?.md"], + ["notes%.md", "notes%.md"], + ["日記.md", "日記.md"], + ])("resolves %s after real Markdown parsing and sanitization", async (href, path) => { + const { click, model } = renderPreview(`[Target](${href})`); + await click(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, ["wsh://local/C:/docs", path]); + expect(model.goHistory).toHaveBeenCalledOnce(); + expect(openLink).not.toHaveBeenCalled(); + }); + + it("preserves HTTPS dispatch through the real parser", async () => { + const { click, model } = renderPreview("[Web](https://example.com/path#section)"); + await click(); + expect(openLink).toHaveBeenCalledExactlyOnceWith("https://example.com/path#section"); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(model.goHistory).not.toHaveBeenCalled(); + }); + + it.each(["[Unsafe](javascript:alert%281%29)", 'Unsafe'])( + "does not dispatch links rejected by the existing parser/sanitizer: %s", + async (text) => { + const { click, model, anchor } = renderPreview(text); + expect(anchor.props.href ?? "").toBe(""); + await click(); + expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled(); + expect(model.goHistory).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + } + ); + + it.each(["connection", "document"])("ignores delayed resolution after changing the %s", async (change) => { + let complete: (fileInfo: FileInfo) => void; + vi.mocked(RpcApi.FileJoinCommand).mockImplementationOnce( + () => new Promise((resolve) => (complete = resolve)) + ); + const { click, model } = renderPreview("[Target](target.md)", "host-A", "/docs/start.md", "/docs"); + const pendingClick = click(); + expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [ + "wsh://host-A//docs", + "target.md", + ]); + if (change === "connection") { + globalStore.set(model.connection, "host-B"); + } else { + globalStore.set(model.metaFilePath, "/other/new.md"); + } + complete({ path: "/docs/target.md" } as FileInfo); + await pendingClick; + expect(model.goHistory).not.toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + expect(globalStore.get(model.connection)).toBe(change === "connection" ? "host-B" : "host-A"); + expect(globalStore.get(model.metaFilePath)).toBe(change === "document" ? "/other/new.md" : "/docs/start.md"); + }); +}); diff --git a/frontend/app/view/preview/preview-markdown.tsx b/frontend/app/view/preview/preview-markdown.tsx index d47248d457..39dbfa6e0b 100644 --- a/frontend/app/view/preview/preview-markdown.tsx +++ b/frontend/app/view/preview/preview-markdown.tsx @@ -18,6 +18,7 @@ function MarkdownPreview({ model }: SpecializedViewProps) { }; }, []); const connName = useAtomValue(model.connection); + const filePath = useAtomValue(model.metaFilePath); const fileInfo = useAtomValue(model.statFile); const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize")); const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize")); @@ -33,6 +34,16 @@ function MarkdownPreview({ model }: SpecializedViewProps) { textAtom={model.fileContent} showTocAtom={model.markdownShowToc} resolveOpts={resolveOpts} + onOpenFile={async (path) => { + // Do not apply a delayed link resolution to a different document or connection. + if ( + globalStore.get(model.connectionImmediate) !== connName || + globalStore.get(model.metaFilePath) !== filePath + ) { + return; + } + await model.goHistory(path); + }} fontSizeOverride={fontSizeOverride} fixedFontSizeOverride={fixedFontSizeOverride} contentClassName="pt-[5px] pr-[15px] pb-[10px] pl-[15px]"