From 8a20fa14ae71a6f0fba4c3ef68df4bd16fb5bd27 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:19:28 -0800 Subject: [PATCH] Use custom hovers in rendered markdown for link titles We were already doing this for chat. I think it makes sense to be consistent --- src/vs/base/browser/markdownRenderer.ts | 19 ++++++++-- .../test/browser/markdownRenderer.test.ts | 2 +- .../markdown/browser/markdownRenderer.ts | 35 ++++++++++++++++++- .../browser/chatContentMarkdownRenderer.ts | 32 +---------------- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 217355461c0..5c3636710db 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../nls.js'; import { onUnexpectedError } from '../common/errors.js'; import { escapeDoubleQuotes, IMarkdownString, MarkdownStringTrustedOptions, parseHrefAndDimensions, removeMarkdownEscapes } from '../common/htmlContent.js'; import { markdownEscapeEscapedIcons } from '../common/iconLabels.js'; @@ -12,7 +13,7 @@ import { Lazy } from '../common/lazy.js'; import { DisposableStore, IDisposable } from '../common/lifecycle.js'; import * as marked from '../common/marked/marked.js'; import { parse } from '../common/marshalling.js'; -import { FileAccess, Schemas } from '../common/network.js'; +import { FileAccess, matchesScheme, Schemas } from '../common/network.js'; import { cloneAndChange } from '../common/objects.js'; import { dirname, resolvePath } from '../common/resources.js'; import { escape } from '../common/strings.js'; @@ -101,9 +102,21 @@ const defaultMarkedRenderers = Object.freeze({ text = removeMarkdownEscapes(text); } - title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; + title = typeof title === 'string' ? removeMarkdownEscapes(title) : ''; href = removeMarkdownEscapes(href); + // Try adding a basic title for command uris if none exists + if (!title) { + try { + const uri = URI.parse(href); + if (matchesScheme(uri, Schemas.command)) { + title = localize('markdown.commandLinkTitle', "Run command: '{0}'", uri.path); + } + } catch { + // Noop + } + } + // HTML Encode href href = href.replace(/&/g, '&') .replace(/${text}`; + return `${text}`; }, }); diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index 5a02b84c7dc..116e0535360 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -285,7 +285,7 @@ suite('MarkdownRenderer', () => { }); const result: HTMLElement = store.add(renderMarkdown(md)).element; - assert.strictEqual(result.innerHTML, `
`); + assert.strictEqual(result.innerHTML, ``); }); test('Should remove relative links if there is no base url', () => { diff --git a/src/vs/platform/markdown/browser/markdownRenderer.ts b/src/vs/platform/markdown/browser/markdownRenderer.ts index 1217633b485..1337baee9bd 100644 --- a/src/vs/platform/markdown/browser/markdownRenderer.ts +++ b/src/vs/platform/markdown/browser/markdownRenderer.ts @@ -6,6 +6,8 @@ import { IRenderedMarkdown, MarkdownRenderOptions, renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { IMarkdownString, MarkdownStringTrustedOptions } from '../../../base/common/htmlContent.js'; +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IHoverService } from '../../hover/browser/hover.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IOpenerService } from '../../opener/common/opener.js'; @@ -58,6 +60,7 @@ export class MarkdownRendererService implements IMarkdownRendererService { private _defaultCodeBlockRenderer: IMarkdownCodeBlockRenderer | undefined; constructor( + @IHoverService private readonly _hoverService: IHoverService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -78,12 +81,42 @@ export class MarkdownRendererService implements IMarkdownRendererService { const rendered = renderMarkdown(markdown, resolvedOptions, outElement); rendered.element.classList.add('rendered-markdown'); - return rendered; + const hoverDisposables = this.attachCustomHovers(rendered.element); + return { + element: rendered.element, + dispose: () => { + rendered.dispose(); + hoverDisposables.dispose(); + } + }; } setDefaultCodeBlockRenderer(renderer: IMarkdownCodeBlockRenderer): void { this._defaultCodeBlockRenderer = renderer; } + + /** + * Replace the native title tooltips on links with custom hover tooltips + */ + private attachCustomHovers(element: HTMLElement): IDisposable { + const store = new DisposableStore(); + + // eslint-disable-next-line no-restricted-syntax + for (const a of element.querySelectorAll('a')) { + if (a.title) { + const title = a.title; + a.title = ''; + store.add(this._hoverService.setupDelayedHover(a, { + content: title, + appearance: { + compact: true + }, + })); + } + } + + return store; + } } export async function openLinkFromMarkdown(openerService: IOpenerService, link: string, isTrusted: boolean | MarkdownStringTrustedOptions | undefined, skipValidation?: boolean): Promise