mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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).
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
// @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);
|
|
});
|
|
});
|