diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index 45af01e0ff..182ff386d9 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: 0114e8cf3a7024273ff5973f0fb09a28fc3244cb -2026-09-15-markdown-file-preview-links.zh.md: f872abb18c26716f4a169958bb7215ef4ac16685 +2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b +2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index 0114e8cf3a..d9d535afc5 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -10,7 +10,7 @@ Assistant explanations link to existing source files that the turn does not modi ## Decision -Settled Assistant Markdown passes explicit local link destinations to the Chat file opener. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. +Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert. @@ -24,8 +24,10 @@ The Web file-reference prompt asks for a link on every existing-file mention out **Add a preview service.** Chat already supplies the required opener and line parameter. +**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes. + **Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation. ## Consequences -Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index f872abb18c..63c18e284b 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -10,7 +10,7 @@ Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若 ## Decision -落定后的 Assistant Markdown 将显式本地链接目标传给 Chat 文件打开器。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 +落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。 @@ -24,8 +24,10 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 **增加预览服务。** Chat 已提供所需打开器和行号参数。 +**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。 + **文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。 ## Consequences -源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 81c74cefb6..7b0bcb7ce2 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 5ccbb29d98c5693dd0d9da8a4b6b5a03522c4c57 -README.zh.md: 4013f2894067cc506dcc923eeb5cf82dcf81b855 +README.md: 0d4e3376ffc34247490cc72a268120a9fe3180fa +README.zh.md: 2494e932e1d143f6d167ad6044fa34d8501b367c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5ccbb29d98..0d4e3376ff 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -30,7 +30,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4013f28940..2494e932e1 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ## 引用预览 -Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 44d2f1f558..3212801e00 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -38,8 +38,6 @@ export interface AssistantMarkdownProps { revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined - /** Open an authored Markdown file link in the Session's sidebar preview. */ - openFile?: ChatNodeOwnerProps['openFile'] | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -47,7 +45,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, renderMessageImages, - reasoningHidden = false, revealProcess, mentions, openFile, t, + reasoningHidden = false, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -81,7 +79,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ labels={labels} fileMentions={mentions} pathImages={pathImages} - openFile={openFile} />, ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index abdbc89665..399897a9b5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -35,7 +35,6 @@ export const AssistantNodeView = memo(function AssistantNodeView({ reasoningHidden={reasoningHidden} revealProcess={revealProcess} mentions={mentions} - openFile={openFile} t={t} /> ) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3ad01f83b3..4c407c8e8a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -782,7 +782,7 @@ export function ChatView({ )} - + void -const MarkdownDelegateContext = createContext(undefined) +/** Navigation capabilities supplied by the nearest Markdown owner. */ +export interface MarkdownDelegate { + /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */ + readonly openExternalLink?: MarkdownExternalLinkHandler | undefined + /** + * Open a decoded local destination from settled Markdown; absent handlers leave plain text. + * @param path - Absolute or workspace-relative file path. + * @param options - First line to reveal when the destination specifies a line or range. + */ + readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined +} + +const MarkdownDelegateContext = createContext({}) /** Props for one Markdown navigation scope. */ -export interface MarkdownDelegateProviderProps { +export interface MarkdownDelegateProviderProps extends MarkdownDelegate { readonly children: ReactNode - readonly openExternalLink: MarkdownExternalLinkHandler } /** - * Delegate ordinary Markdown HTTP(S) activation without threading callbacks through renderers. - * @param props - child tree and its link handler. + * Scope Markdown navigation without threading callbacks through renderers. + * Nested providers replace the enclosing capabilities. Handler changes reach cached links. + * @param props - Child tree and its file and HTTP(S) link handlers. * @returns the scoped child tree. */ export function MarkdownDelegateProvider({ children, openExternalLink, + openFile, }: MarkdownDelegateProviderProps): ReactNode { + const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile]) return ( - + {children} ) } -/** Read the nearest optional Markdown HTTP(S) navigation delegate. */ -export function useMarkdownExternalLinkDelegate(): MarkdownExternalLinkHandler | undefined { +/** + * Read the nearest Markdown navigation capabilities. + * @returns Owner callbacks, or an empty delegate outside a provider. + */ +export function useMarkdownDelegate(): MarkdownDelegate { return useContext(MarkdownDelegateContext) } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 67e0ac8980..2977db6042 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -36,7 +36,6 @@ function renderSettled( previews: FencePreviewCatalog | undefined, fileMentions: MarkdownFileMentions | undefined, pathImages: MarkdownPathImages | undefined, - openFile: MarkdownRenderContext['openFile'], ): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() @@ -47,7 +46,6 @@ function renderSettled( previews, fileMentions, pathImages, - openFile, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -178,21 +176,20 @@ class StreamingRenderer { * modified clicks retain native behavior. `variant="compact"` uses secondary * text sizing, uniform bold headings, and tight block spacing; the default * `body` variant uses the full document typography. - * `openFile` enables local Markdown links in settled messages, including - * `#L24` and `#L24-L30` destinations (ranges open at their first line). + * The provider's `openFile` enables local Markdown links in settled messages, + * including `#L24` and `#L24-L30` destinations (ranges open at their first line). * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and * unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. */ export const MarkdownText = memo(function MarkdownText({ - text, streaming = false, labels, fileMentions, pathImages, openFile, variant = 'body', + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', }: { text: string streaming?: boolean labels: MarkdownLabels fileMentions?: MarkdownFileMentions | undefined pathImages?: MarkdownPathImages | undefined - openFile?: MarkdownRenderContext['openFile'] variant?: 'body' | 'compact' }) { const streamRef = useRef(null) @@ -201,14 +198,14 @@ export const MarkdownText = memo(function MarkdownText({ const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, labels, previews, fileMentions, pathImages, openFile) + return renderSettled(text, labels, previews, fileMentions, pathImages) } if (streamRef.current === null || streamLabelsRef.current !== labels) { streamRef.current = new StreamingRenderer(labels, previews) streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, labels, previews, fileMentions, pathImages, openFile]) + }, [text, streaming, labels, previews, fileMentions, pathImages]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index ab947fe3a8..3815056e8c 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -31,7 +31,7 @@ import { renderMermaid } from './mermaid.ts' import type { PreviewLabels } from './SourcePreview.tsx' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' -import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx' +import { useMarkdownDelegate } from './MarkdownDelegate.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' @@ -233,8 +233,6 @@ export interface MarkdownFileMentions { * numbering accumulated in document order while references render. */ export interface MarkdownRenderContext { - /** Opens authored local file links in the owner's preview; settled renders only. */ - readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */ readonly streaming: boolean /** Localized fence copy-button labels. */ @@ -403,7 +401,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'link': return renderAnchor( node.url, renderChildren(node.children, { ...context, inLink: true }), key, - !anchorWrapsOnlyImages(node.children), context.openFile, + !anchorWrapsOnlyImages(node.children), context.streaming, ) case 'linkReference': return renderLinkReference(node, key, context) @@ -614,7 +612,7 @@ function MarkdownAnchor({ href, glyph, children }: { readonly glyph: boolean readonly children: ReactNode[] }): ReactNode { - const openExternalLink = useMarkdownExternalLinkDelegate() + const { openExternalLink } = useMarkdownDelegate() const external = ['http:', 'https:'].includes(new URL(href).protocol) const open = external ? openExternalLink : undefined return ( @@ -633,26 +631,35 @@ function MarkdownAnchor({ href, glyph, children }: { ) } -/** Local destinations use the preview callback; external destinations use the URL allowlist. */ -function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, openFile?: MarkdownRenderContext['openFile']): ReactNode { - const file = openFile === undefined ? undefined : parseFileLink(url) - if (file !== undefined && openFile !== undefined) { - return ( - - ) +/** Local destinations use the scoped file delegate after settlement. */ +function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode { + const file = streaming ? undefined : parseFileLink(url) + if (file !== undefined) { + return {children} } return renderSafeLink(normalizeUri(url), children, key, glyph) } +function MarkdownFileLink({ file, glyph, children }: { + readonly file: { path: string; line?: number } + readonly glyph: boolean + readonly children: ReactNode[] +}): ReactNode { + const { openFile } = useMarkdownDelegate() + if (openFile === undefined) return <>{children} + return ( + + ) +} + /** * The complete inline-code value when it is exactly an absolute HTTP(S) URL * (no surrounding whitespace); anything else stays inert code. @@ -714,7 +721,7 @@ function renderLinkReference( return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } const rendered = renderChildren(node.children, { ...context, inLink: true }) - return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.openFile) + return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.streaming) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx index ceb7257163..bff4bcac36 100644 --- a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider } from '../src/index.ts' import { MarkdownText } from './markdown-test-components.tsx' afterEach(cleanup) @@ -16,7 +17,11 @@ describe('Markdown file links', () => { ['file%23name%3F.txt', 'file#name?.txt', undefined], ])('opens %s through the preview callback', (target, path, options) => { const openFile = vi.fn() - const view = render() + const view = render( + + + , + ) const link = view.getByRole('button', { name: 'source' }) expect(link.getAttribute('title')).toBe(path) fireEvent.click(link) @@ -25,23 +30,34 @@ describe('Markdown file links', () => { }) it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => { - const view = render() + const view = render( + + + , + ) expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy() }) it('preserves an image label’s alternative text as the accessible name', () => { - const view = render() + const view = render( + + + , + ) expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy() }) it('handles reference links and code labels without nesting file-mention buttons', () => { const openFile = vi.fn() const resolve = vi.fn() - const view = render() + const view = render( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'see index.ts' })) expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 }) expect(resolve).not.toHaveBeenCalled() @@ -56,7 +72,11 @@ describe('Markdown file links', () => { 'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992', ])('keeps unsupported destination %s inert', (target) => { const openFile = vi.fn() - const view = render() + const view = render( + + + , + ) expect(view.getByText('source')).toBeTruthy() expect(view.container.querySelector('button, a')).toBeNull() expect(openFile).not.toHaveBeenCalled() @@ -73,14 +93,67 @@ describe('Markdown file links', () => { const first = vi.fn() const second = vi.fn() const text = '[source](src/a.ts)\n\nmore\n\n' - const view = render() + const view = render( + + + , + ) expect(view.queryByRole('button')).toBeNull() - view.rerender() + view.rerender( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'source' })) expect(first).toHaveBeenCalledOnce() - view.rerender() + view.rerender( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'source' })) expect(second).toHaveBeenCalledOnce() expect(first).toHaveBeenCalledOnce() }) + + it('routes file and HTTP(S) links through the same provider', () => { + const openFile = vi.fn() + const openExternalLink = vi.fn() + const view = render( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(openFile).toHaveBeenCalledExactlyOnceWith('src/a.ts', { line: 4 }) + expect(openExternalLink).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('link', { name: 'web' })) + expect(openExternalLink).toHaveBeenCalledExactlyOnceWith('https://example.com') + expect(openFile).toHaveBeenCalledOnce() + }) + + it('updates cached links when the nearest provider gains, replaces, or removes its file handler', () => { + const outer = vi.fn() + const first = vi.fn() + const second = vi.fn() + const markdown = + const scope = (openFile?: (path: string) => void) => ( + + {markdown} + + ) + const view = render(scope()) + expect(view.queryByRole('button')).toBeNull() + view.rerender(scope(first)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(first).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + view.rerender(scope(second)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(second).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + expect(first).toHaveBeenCalledOnce() + view.rerender(scope()) + expect(view.queryByRole('button')).toBeNull() + expect(view.getByText('source')).toBeTruthy() + expect(outer).not.toHaveBeenCalled() + }) })