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, `

command1 command2

`); + assert.strictEqual(result.innerHTML, `

command1 command2

`); }); 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 { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatContentMarkdownRenderer.ts index 6855178fa58..cff3fda86ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentMarkdownRenderer.ts @@ -5,14 +5,8 @@ import { $ } from '../../../../base/browser/dom.js'; import { IRenderedMarkdown, MarkdownRenderOptions } from '../../../../base/browser/markdownRenderer.js'; -import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; -import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import product from '../../../../platform/product/common/product.js'; export const allowedChatMarkdownHtmlTags = Object.freeze([ @@ -62,10 +56,6 @@ export const allowedChatMarkdownHtmlTags = Object.freeze([ */ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { constructor( - @ILanguageService languageService: ILanguageService, - @IOpenerService openerService: IOpenerService, - @IConfigurationService configurationService: IConfigurationService, - @IHoverService private readonly hoverService: IHoverService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, ) { } @@ -103,26 +93,6 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { child.replaceWith($('p', undefined, child.textContent)); } } - return this.attachCustomHover(result); - } - - private attachCustomHover(result: IRenderedMarkdown): IRenderedMarkdown { - const store = new DisposableStore(); - // eslint-disable-next-line no-restricted-syntax - result.element.querySelectorAll('a').forEach((element) => { - if (element.title) { - const title = element.title; - element.title = ''; - store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title)); - } - }); - - return { - element: result.element, - dispose: () => { - result.dispose(); - store.dispose(); - } - }; + return result; } }