fix(web): route chat file links to workspace preview instead of new window (#1239) (#2576)

* fix(web): route chat file links to workspace preview instead of new window (#1239)

Chat-emitted markdown links like `[template.html](template.html)` rendered
as `<a target="_blank">` with no click handler. In Electron that hits
`setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
hrefs have no base so the new window can't resolve them and the user
lands on the home screen — the file they wanted to preview is never
shown.

Detect in-project file paths in chat markdown via a new
`asInProjectFilePath` helper and route them through the existing
`requestOpenFile` workspace tab opener. External URLs, `mailto:`,
`#anchors`, absolute paths and `..` traversal keep their default
browser-link behavior. The `renderMarkdown(options)` extension is
backwards-compatible: existing callers (file viewer, system reminders)
keep their default `target="_blank"` behavior when the option is
omitted.

Closes #1239.

* fix(web): decode percent-encoded chat file links before workspace open (#1239)

Chat markdown frequently emits links as URL-encoded text — `Mock%20Page.html`
for a file named `Mock Page.html`, multi-byte sequences for non-ASCII
filenames. The workspace tab opener (`requestOpenFile` →
`FileWorkspace`) matches by literal on-disk file name, so handing it
the raw `%20`-encoded form silently misses the existing tab and the
user sees nothing happen on click — the exact regression #1239
reopened against.

Decode after the literal `..` check and re-check `..` on the
decoded form so a `%2E%2E` smuggling attempt cannot bypass the
traversal guard. Malformed encodings fall through to `null` (default
browser link behavior) instead of letting URIError crash the
renderer.

The same gap was flagged on the earlier draft PR #1255 by mrcfps and
lefarcen (P2) but never landed there; this PR now covers it with
five new regression tests (ASCII spaces, nested subdirs, UTF-8 byte
sequences, malformed `%`, percent-encoded traversal).
This commit is contained in:
YOMXXX 2026-05-23 11:48:07 +08:00 committed by GitHub
parent bf699736d4
commit 97ed479c6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 499 additions and 18 deletions

View file

@ -1,7 +1,11 @@
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ToolCard } from "./ToolCard";
import { FileOpsSummary } from "./FileOpsSummary";
import { renderMarkdown } from "../runtime/markdown";
import {
renderMarkdown,
type MarkdownLinkClickHandler,
} from "../runtime/markdown";
import { asInProjectFilePath } from "../runtime/in-project-link";
import { projectFileUrl } from "../providers/registry";
import { submitChatRunToolResult } from "../providers/daemon";
import { useAnalytics } from "../analytics/provider";
@ -239,6 +243,7 @@ export function AssistantMessage({
});
onSubmitForm?.(text);
}}
onRequestOpenFile={onRequestOpenFile}
/>
);
if (b.kind === "thinking")
@ -1215,6 +1220,7 @@ function ProseBlock({
locallySubmitted,
suppressDirectionForms,
onSubmitForm,
onRequestOpenFile,
}: {
text: string;
isLastAssistant: boolean;
@ -1223,9 +1229,23 @@ function ProseBlock({
locallySubmitted: Set<string>;
suppressDirectionForms: boolean;
onSubmitForm: (formId: string, text: string) => void;
onRequestOpenFile?: (name: string) => void;
}) {
const cleaned = useMemo(() => stripArtifact(text), [text]);
const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]);
// Route relative file-link clicks (`template.html`, `subdir/hero.html`)
// through the workspace tab opener. Without this, Electron's window-open
// handler creates a new app window whose relative href can't resolve, and
// the user lands on the home screen — the file is never previewed.
const onLinkClick = useMemo<MarkdownLinkClickHandler | undefined>(() => {
if (!onRequestOpenFile) return undefined;
return (href, event) => {
const path = asInProjectFilePath(href);
if (!path) return;
event.preventDefault();
onRequestOpenFile(path);
};
}, [onRequestOpenFile]);
// Each text segment is further split on `<system-reminder>` blocks so
// those render as their own collapsible chip instead of raw markup.
const renderable = segments.flatMap(
@ -1261,7 +1281,11 @@ function ProseBlock({
return <SystemReminderBlock key={seg.key} text={seg.text} />;
}
if (seg.kind === "text") {
return <Fragment key={seg.key}>{renderMarkdown(seg.text)}</Fragment>;
return (
<Fragment key={seg.key}>
{renderMarkdown(seg.text, { onLinkClick })}
</Fragment>
);
}
if (seg.kind === "suppressed-direction") {
return (

View file

@ -0,0 +1,55 @@
/**
* Decide whether a markdown link href in chat output should resolve to
* an in-project file (opened in the right-pane workspace) or fall
* through to the default browser link behavior (Electron
* `setWindowOpenHandler` new window).
*
* Chat output frequently contains references like
* `[template.html](template.html)` or `[hero](subdir/hero.html)`. Those
* are relative paths into the current project's file workspace; with
* default `target="_blank"` they open a new Electron window with no
* project context and land on the home screen. Routing them through
* the existing `requestOpenFile` callback keeps the user in the same
* project view and previews the file in the right pane.
*
* Returns the normalized file path when the href looks like an
* in-project link, or `null` to let the default link behavior win.
*/
export function asInProjectFilePath(href: string | null | undefined): string | null {
if (typeof href !== 'string') return null;
const trimmed = href.trim();
if (!trimmed) return null;
if (trimmed.startsWith('#')) return null;
// RFC 3986 scheme: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by `:`.
// Catches http:, https:, mailto:, file:, od:, blob:, javascript:, etc.
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
if (trimmed.startsWith('/')) return null;
const stripped = trimmed.startsWith('./') ? trimmed.slice(2) : trimmed;
// Refuse any `..` segment so a relative path can't climb out of the
// project root. Cheaper and safer than full path normalization, and
// assistant chat output never emits `..` for legitimate file refs.
if (stripped.split('/').some((segment) => segment === '..')) return null;
// Strip query and fragment — the workspace tab opener takes a file
// path, not a URL.
const withoutHash = stripped.split('#')[0] ?? stripped;
const withoutQuery = withoutHash.split('?')[0] ?? withoutHash;
if (!withoutQuery) return null;
// Chat markdown emits links as URL-encoded text (`Mock%20Page.html`
// for a file named `Mock Page.html`, multi-byte sequences for
// non-ASCII names). The workspace tab opener
// (`requestOpenFile` → `FileWorkspace`) matches by literal on-disk
// file name, so passing the encoded form silently misses the tab.
// Decode after the literal `..` check so a `%2E%2E` smuggling
// attempt cannot bypass the traversal guard, and re-check `..` on
// the decoded form. Treat malformed encodings as "not a real
// in-project link" rather than letting the URIError crash the
// renderer.
let decoded: string;
try {
decoded = decodeURIComponent(withoutQuery);
} catch {
return null;
}
if (decoded.split('/').some((segment) => segment === '..')) return null;
return decoded;
}

View file

@ -11,13 +11,30 @@
* Output is a React fragment of typed elements no dangerouslySetInnerHTML,
* so untrusted text can't smuggle markup through.
*/
import { Fragment, type ReactNode } from 'react';
import { Fragment, type MouseEvent, type ReactNode } from 'react';
export function renderMarkdown(input: string): ReactNode {
export type MarkdownLinkClickHandler = (
href: string,
event: MouseEvent<HTMLAnchorElement>,
) => void;
export interface RenderMarkdownOptions {
/**
* Fired on every rendered `<a>` click before the default link
* behavior. Callers that want to intercept (e.g. route in-project
* file links to a workspace tab opener instead of letting Electron
* open a new window) must call `event.preventDefault()` themselves.
* Omitting the option keeps the previous default `target="_blank"`
* behavior for every link.
*/
onLinkClick?: MarkdownLinkClickHandler;
}
export function renderMarkdown(input: string, options?: RenderMarkdownOptions): ReactNode {
const blocks = parseBlocks(input);
return (
<>
{blocks.map((b, i) => renderBlock(b, i))}
{blocks.map((b, i) => renderBlock(b, i, options))}
</>
);
}
@ -194,19 +211,19 @@ function parseBlocks(input: string): Block[] {
return out;
}
function renderBlock(block: Block, key: number): ReactNode {
function renderBlock(block: Block, key: number, options?: RenderMarkdownOptions): ReactNode {
if (block.kind === 'p') {
return <p key={key} className="md-p">{renderInline(block.text)}</p>;
return <p key={key} className="md-p">{renderInline(block.text, options)}</p>;
}
if (block.kind === 'h') {
const Tag = (`h${block.level}` as 'h1' | 'h2' | 'h3' | 'h4');
return <Tag key={key} className={`md-h md-h${block.level}`}>{renderInline(block.text)}</Tag>;
return <Tag key={key} className={`md-h md-h${block.level}`}>{renderInline(block.text, options)}</Tag>;
}
if (block.kind === 'ul') {
return (
<ul key={key} className="md-ul">
{block.items.map((item, i) => (
<li key={i}>{renderInline(item)}</li>
<li key={i}>{renderInline(item, options)}</li>
))}
</ul>
);
@ -215,7 +232,7 @@ function renderBlock(block: Block, key: number): ReactNode {
return (
<ol key={key} className="md-ol">
{block.items.map((item, i) => (
<li key={i}>{renderInline(item)}</li>
<li key={i}>{renderInline(item, options)}</li>
))}
</ol>
);
@ -239,7 +256,7 @@ function renderBlock(block: Block, key: number): ReactNode {
<thead>
<tr>
{headers.map((cell, idx) => (
<th key={idx} style={cellStyle(idx)}>{renderInline(cell)}</th>
<th key={idx} style={cellStyle(idx)}>{renderInline(cell, options)}</th>
))}
</tr>
</thead>
@ -247,7 +264,7 @@ function renderBlock(block: Block, key: number): ReactNode {
{rows.map((row, rIdx) => (
<tr key={rIdx}>
{headers.map((_, cIdx) => (
<td key={cIdx} style={cellStyle(cIdx)}>{renderInline(row[cIdx] ?? '')}</td>
<td key={cIdx} style={cellStyle(cIdx)}>{renderInline(row[cIdx] ?? '', options)}</td>
))}
</tr>
))}
@ -284,8 +301,12 @@ function isSafeMarkdownImageSrc(src: string): boolean {
// and plain text. We walk the string with a regex that matches whichever
// delimiter shows up next; everything between delimiters becomes a text
// span (which itself still gets autolink scanning).
function renderInline(text: string): ReactNode {
function renderInline(text: string, options?: RenderMarkdownOptions): ReactNode {
const out: ReactNode[] = [];
const onLinkClick = options?.onLinkClick;
const linkClickHandler = onLinkClick
? (href: string) => (event: MouseEvent<HTMLAnchorElement>) => onLinkClick(href, event)
: undefined;
// Order matters:
// 1. inline code first so its contents are not re-tokenized as bold/italic.
// 2. image syntax `![alt](url)` BEFORE the link branch. Both share
@ -306,7 +327,7 @@ function renderInline(text: string): ReactNode {
let key = 0;
while ((m = re.exec(text))) {
if (m.index > lastIndex) {
pushText(out, text.slice(lastIndex, m.index), key++);
pushText(out, text.slice(lastIndex, m.index), key++, options);
}
if (m[1]) {
out.push(
@ -333,16 +354,18 @@ function renderInline(text: string): ReactNode {
} else {
// Unsafe scheme — drop the image tag but keep the alt text so
// the user sees what the model meant to show.
pushText(out, alt, key++);
pushText(out, alt, key++, options);
}
} else if (m[4] && m[5]) {
const href = m[5];
out.push(
<a
key={key++}
className="md-link"
href={m[5]}
href={href}
target="_blank"
rel="noreferrer noopener"
onClick={linkClickHandler?.(href)}
>
{m[4]}
</a>,
@ -358,6 +381,7 @@ function renderInline(text: string): ReactNode {
href={href}
target="_blank"
rel="noreferrer noopener"
onClick={linkClickHandler?.(href)}
>
{href}
</a>,
@ -375,7 +399,7 @@ function renderInline(text: string): ReactNode {
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) {
pushText(out, text.slice(lastIndex), key++);
pushText(out, text.slice(lastIndex), key++, options);
}
return <Fragment>{out}</Fragment>;
}
@ -384,8 +408,9 @@ function renderInline(text: string): ReactNode {
// text nodes. Newlines inside a paragraph become explicit <br />s — the
// upstream parser has already left them in place because chat output
// often relies on hard line breaks rather than blank-line separation.
function pushText(out: ReactNode[], text: string, baseKey: number): void {
function pushText(out: ReactNode[], text: string, baseKey: number, options?: RenderMarkdownOptions): void {
if (!text) return;
const onLinkClick = options?.onLinkClick;
const urlRe = /(https?:\/\/[^\s)]+)/g;
const segments: ReactNode[] = [];
let lastIndex = 0;
@ -403,6 +428,7 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
href={href}
target="_blank"
rel="noreferrer noopener"
onClick={onLinkClick ? (event) => onLinkClick(href, event) : undefined}
>
{href}
</a>,

View file

@ -0,0 +1,134 @@
// @vitest-environment jsdom
/**
* End-to-end coverage for chat file-link routing (issue #1239).
*
* Before this fix, every `<a>` rendered from chat markdown carried
* `target="_blank"` with no `onClick`. In Electron that hits the desktop
* `setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
* hrefs like `template.html` have no base so the new window can't resolve
* them and the user lands on the home screen. The fix detects in-project
* file paths in chat markdown and routes them through the existing
* `requestOpenFile` workspace tab opener.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { ChatMessage } from '../../src/types';
afterEach(() => cleanup());
function messageWithText(text: string): ChatMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: text,
events: [{ kind: 'text', text }],
startedAt: 1_000,
endedAt: 3_000,
runStatus: 'succeeded',
};
}
describe('AssistantMessage — chat file-link routing (#1239)', () => {
it('routes a relative file-link click through onRequestOpenFile and suppresses the default new-window behavior', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Open [template.html](template.html) to preview.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('template.html');
// Dispatch a real DOM MouseEvent so defaultPrevented reflects what
// Electron's setWindowOpenHandler actually reads.
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('template.html');
expect(clickEvent.defaultPrevented).toBe(true);
});
it('normalizes ./ and nested subdirectory paths before opening', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Inspect [hero](./subdir/hero.html) section.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('subdir/hero.html');
});
it('does not intercept external https:// URLs — preserves default target="_blank" behavior', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('See [docs](https://example.com/docs) for context.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('https://example.com/docs');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).not.toHaveBeenCalled();
expect(clickEvent.defaultPrevented).toBe(false);
});
it('does not intercept #anchor fragments', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Jump to [intro](#intro) of this page.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onRequestOpenFile).not.toHaveBeenCalled();
});
it('keeps default link behavior when the host did not pass onRequestOpenFile', () => {
// Some surfaces (e.g. read-only history view) intentionally do not
// pass `onRequestOpenFile`. The fix must not throw and the link must
// still render with its default target="_blank" behavior.
const { container } = render(
<AssistantMessage
message={messageWithText('Open [template.html](template.html) to preview.')}
streaming={false}
projectId="project-1"
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('target')).toBe('_blank');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
});
});

View file

@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import { asInProjectFilePath } from '../../src/runtime/in-project-link';
describe('asInProjectFilePath', () => {
describe('intercepts (returns normalized path)', () => {
it('bare filename → unchanged', () => {
expect(asInProjectFilePath('template.html')).toBe('template.html');
});
it('strips a leading ./ prefix', () => {
expect(asInProjectFilePath('./template.html')).toBe('template.html');
});
it('keeps subdirectory paths intact', () => {
expect(asInProjectFilePath('subdir/hero.html')).toBe('subdir/hero.html');
});
it('strips ./ in front of a subdirectory path', () => {
expect(asInProjectFilePath('./subdir/hero.html')).toBe('subdir/hero.html');
});
it('drops a trailing query string', () => {
expect(asInProjectFilePath('template.html?v=2')).toBe('template.html');
});
it('drops a trailing fragment', () => {
expect(asInProjectFilePath('template.html#section')).toBe('template.html');
});
it('drops both query and fragment together', () => {
expect(asInProjectFilePath('template.html?v=2#section')).toBe('template.html');
});
it('trims surrounding whitespace from the href', () => {
expect(asInProjectFilePath(' template.html ')).toBe('template.html');
});
it('handles the exact long filename shape from the issue screenshot', () => {
expect(asInProjectFilePath('orbit-daily-digest-general-2026-05-11.html'))
.toBe('orbit-daily-digest-general-2026-05-11.html');
});
it('decodes percent-encoded filenames so the workspace tab opener matches the on-disk file', () => {
// Chat markdown frequently emits links like
// `[Mock page](Mock%20Page.html)` because the autolink path
// percent-encodes spaces. The workspace tab opener
// (`requestOpenFile` → `FileWorkspace`) matches by literal
// on-disk file name, so handing it the raw `Mock%20Page.html`
// would silently miss the existing tab. Decode the result
// before returning. Earlier draft PR #1255 hit this exact
// miss in review (mrcfps / lefarcen P2).
expect(asInProjectFilePath('Mock%20Page.html')).toBe('Mock Page.html');
});
it('decodes percent-encoded subdirectory paths', () => {
expect(asInProjectFilePath('Visual%20Direction/hero%20alt.html')).toBe(
'Visual Direction/hero alt.html',
);
});
it('decodes non-ASCII (UTF-8 percent-encoded) filenames', () => {
// Chinese / Cyrillic / accented filenames percent-encode into
// multi-byte sequences. `decodeURIComponent` handles them
// correctly; the catch arm below keeps malformed encodings
// from throwing.
expect(asInProjectFilePath('%E9%A6%96%E9%A1%B5.html')).toBe('首页.html');
});
it('returns null for malformed percent-encoding rather than throwing', () => {
// A stray `%` (e.g. `Read%this.html` where the user meant a
// literal percent) makes decodeURIComponent throw a URIError.
// We never want a chat link to crash the renderer — fall
// through to the default browser behavior instead.
expect(asInProjectFilePath('Read%this.html')).toBeNull();
});
});
describe('passes through (returns null) — external schemes', () => {
it('http://', () => {
expect(asInProjectFilePath('http://example.com/x')).toBeNull();
});
it('https://', () => {
expect(asInProjectFilePath('https://example.com/x')).toBeNull();
});
it('mailto:', () => {
expect(asInProjectFilePath('mailto:foo@bar.com')).toBeNull();
});
it('Electron od: protocol', () => {
expect(asInProjectFilePath('od://app/projects/123')).toBeNull();
});
it('blob: URLs', () => {
expect(asInProjectFilePath('blob:https://example.com/abc')).toBeNull();
});
it('file:// URLs (NOT in-project relative paths)', () => {
expect(asInProjectFilePath('file:///etc/passwd')).toBeNull();
});
it('javascript: scheme is refused even though it matches the RFC grammar', () => {
expect(asInProjectFilePath('javascript:alert(1)')).toBeNull();
});
});
describe('passes through (returns null) — non-link or unsafe shapes', () => {
it('null', () => {
expect(asInProjectFilePath(null)).toBeNull();
});
it('undefined', () => {
expect(asInProjectFilePath(undefined)).toBeNull();
});
it('empty string', () => {
expect(asInProjectFilePath('')).toBeNull();
});
it('whitespace-only string', () => {
expect(asInProjectFilePath(' ')).toBeNull();
});
it('#fragment-only — anchor within the same document', () => {
expect(asInProjectFilePath('#section')).toBeNull();
});
it('absolute path starting with / — could mean filesystem root in Electron', () => {
expect(asInProjectFilePath('/abs/path.html')).toBeNull();
});
it('parent-traversal `..` — refuses to climb out of the project root', () => {
expect(asInProjectFilePath('..')).toBeNull();
});
it('relative path that walks up via .. — refused', () => {
expect(asInProjectFilePath('../sibling.html')).toBeNull();
});
it('mid-path .. segment is still refused', () => {
expect(asInProjectFilePath('a/../b.html')).toBeNull();
});
it('refuses a `..` segment smuggled in via percent-encoding (`%2E%2E`)', () => {
// Decoding happens after the literal-`..` check; without an
// additional post-decode check a hostile chat link could
// bypass the traversal guard by writing `%2E%2E/secret.html`
// and the workspace opener would receive `../secret.html`.
expect(asInProjectFilePath('%2E%2E/secret.html')).toBeNull();
expect(asInProjectFilePath('a/%2E%2E/b.html')).toBeNull();
});
});
});

View file

@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderMarkdown } from '../../src/runtime/markdown';
describe('renderMarkdown — onLinkClick option', () => {
afterEach(() => cleanup());
it('omits onClick when the option is absent (backwards-compat for existing callers)', () => {
// Existing surfaces — file viewer, system reminders, anywhere that
// just renders markdown for display — must keep their previous
// target="_blank" behavior with no extra event wiring.
const { container } = render(
<div>{renderMarkdown('Click [here](https://example.com).')}</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('https://example.com');
expect(anchor?.getAttribute('target')).toBe('_blank');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
});
it('fires onLinkClick on explicit [text](url) link click', () => {
const onLinkClick = vi.fn();
const { container } = render(
<div>
{renderMarkdown('Open [the file](template.html) to inspect.', { onLinkClick })}
</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('template.html');
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('template.html');
});
it('fires onLinkClick on autolinked bare https URLs found inline', () => {
// The bare-URL branch in `renderInline` (`m[6]`) — separate code
// path from the explicit `[text](url)` branch, must wire onClick
// the same way.
const onLinkClick = vi.fn();
const { container } = render(
<div>{renderMarkdown('See https://example.com/page for context.', { onLinkClick })}</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('https://example.com/page');
});
it('fires onLinkClick on URLs that fall to the pushText autolink path', () => {
// Text emitted between other inline tokens flows through `pushText`,
// which runs its own URL autolink scan. That third `<a>` creation
// site needs the same onClick wiring as the other two.
const onLinkClick = vi.fn();
const { container } = render(
<div>
{renderMarkdown('**bold** https://example.com/page then more text.', { onLinkClick })}
</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('https://example.com/page');
});
it('passes the React MouseEvent so the caller can preventDefault()', () => {
const onLinkClick = vi.fn<(href: string, event: { preventDefault(): void }) => void>(
(_href, event) => {
event.preventDefault();
},
);
const { container } = render(
<div>{renderMarkdown('Open [file](template.html).', { onLinkClick })}</div>,
);
const anchor = container.querySelector('a')!;
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor.dispatchEvent(clickEvent);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(clickEvent.defaultPrevented).toBe(true);
});
});