Use custom hovers in rendered markdown for link titles

We were already doing this for chat. I think it makes sense to be consistent
This commit is contained in:
Matt Bierner
2025-11-25 11:19:28 -08:00
parent ca97995de9
commit 8a20fa14ae
4 changed files with 52 additions and 36 deletions
+16 -3
View File
@@ -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(/</g, '&lt;')
@@ -111,7 +124,7 @@ const defaultMarkedRenderers = Object.freeze({
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return `<a href="${href}" title="${title || href}" draggable="false">${text}</a>`;
return `<a href="${escapeDoubleQuotes(href)}" title="${escapeDoubleQuotes(title || href)}" draggable="false">${text}</a>`;
},
});
@@ -285,7 +285,7 @@ suite('MarkdownRenderer', () => {
});
const result: HTMLElement = store.add(renderMarkdown(md)).element;
assert.strictEqual(result.innerHTML, `<p><a href="" title="command:doFoo" draggable="false" data-href="command:doFoo">command1</a> <a href="" data-href="command:doFoo">command2</a></p>`);
assert.strictEqual(result.innerHTML, `<p><a href="" title="Run command: 'doFoo'" draggable="false" data-href="command:doFoo">command1</a> <a href="" data-href="command:doFoo">command2</a></p>`);
});
test('Should remove relative links if there is no base url', () => {
@@ -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<boolean> {
@@ -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;
}
}