Skip to content

Commit f92d54f

Browse files
dileepyavanCopilot
andcommitted
Fix Agent Host file link hover labels
Format default Markdown link hovers with the host-aware label service while preserving navigation targets and custom titles. Add coverage for WSL paths, Windows host formatting, and existing link behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b7881e7 commit f92d54f

2 files changed

Lines changed: 82 additions & 2 deletions

File tree

src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho
99
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
1010
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
1111
import { type MarkedExtension } from '../../../../../base/common/marked/marked.js';
12+
import { URI } from '../../../../../base/common/uri.js';
13+
import { ILabelService } from '../../../../../platform/label/common/label.js';
1214
import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
1315
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
1416
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
@@ -124,6 +126,7 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer {
124126
@IConfigurationService configurationService: IConfigurationService,
125127
@IHoverService private readonly hoverService: IHoverService,
126128
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
129+
@ILabelService private readonly labelService: ILabelService,
127130
) { }
128131

129132
render(markdown: IMarkdownString, options?: MarkdownRenderOptions, outElement?: HTMLElement): IRenderedMarkdown {
@@ -162,7 +165,12 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer {
162165
// eslint-disable-next-line no-restricted-syntax
163166
result.element.querySelectorAll('a').forEach((element) => {
164167
if (element.title) {
165-
const title = element.title;
168+
let title = element.title;
169+
if (title === element.dataset.href && title.startsWith(`${AGENT_HOST_SCHEME}:`)) {
170+
const uri = URI.parse(title);
171+
const label = this.labelService.getUriLabel(uri);
172+
title = uri.fragment ? `${label}#${uri.fragment}` : label;
173+
}
166174
element.title = '';
167175
store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title));
168176
}

src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,93 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7+
import sinon from 'sinon';
78
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
9+
import { OperatingSystem } from '../../../../../../base/common/platform.js';
10+
import { URI } from '../../../../../../base/common/uri.js';
811
import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js';
912
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
13+
import { AGENT_HOST_LABEL_FORMATTER, agentHostAuthority, agentHostLabelFormatter, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
14+
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
15+
import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js';
16+
import { ILabelService } from '../../../../../../platform/label/common/label.js';
1017
import { ChatContentMarkdownRenderer } from '../../../browser/widget/chatContentMarkdownRenderer.js';
1118
import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
1219

1320
suite('ChatMarkdownRenderer', () => {
1421
const store = ensureNoDisposablesAreLeakedInTestSuite();
1522

1623
let testRenderer: ChatContentMarkdownRenderer;
24+
let instantiationService: ReturnType<typeof workbenchInstantiationService>;
1725
setup(() => {
18-
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
26+
instantiationService = store.add(workbenchInstantiationService(undefined, store));
1927
testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer);
2028
});
2129

30+
suite('link hovers', () => {
31+
let setupManagedHover: sinon.SinonSpy<Parameters<IHoverService['setupManagedHover']>, ReturnType<IHoverService['setupManagedHover']>>;
32+
33+
setup(() => {
34+
setupManagedHover = sinon.spy(NullHoverService.setupManagedHover);
35+
instantiationService.stub(IHoverService, { ...NullHoverService, setupManagedHover });
36+
store.add(instantiationService.get(ILabelService).registerFormatter(AGENT_HOST_LABEL_FORMATTER));
37+
testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer);
38+
});
39+
40+
test('shows host paths for transformed and already mapped links without changing their targets', () => {
41+
const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu');
42+
store.add(instantiationService.get(ILabelService).registerFormatter(agentHostLabelFormatter(authority, OperatingSystem.Linux)));
43+
const file = URI.file('/home/user/my project/a&b.ts').with({ fragment: 'L42,7' });
44+
const target = toAgentHostUri(file, authority).toString();
45+
const links = [
46+
{ href: '/home/user/my%20project/a&b.ts:42:7', transformUri: () => target },
47+
{ href: target, transformUri: undefined },
48+
];
49+
50+
const actual = links.map(({ href, transformUri }) => {
51+
const result = store.add(testRenderer.render(new MarkdownString(`[file](${href})`), { transformUri }));
52+
const link = result.element.querySelector('a');
53+
return {
54+
hover: setupManagedHover.lastCall.args[2],
55+
target: link?.dataset.href,
56+
text: link?.textContent,
57+
nativeTitle: link?.title,
58+
};
59+
});
60+
61+
assert.deepStrictEqual(actual, links.map(() => ({
62+
hover: '/home/user/my project/a&b.ts#L42,7',
63+
target,
64+
text: 'file',
65+
nativeTitle: '',
66+
})));
67+
});
68+
69+
test('uses the remote host operating system for path formatting', () => {
70+
const labelService = instantiationService.get(ILabelService);
71+
store.add(labelService.registerFormatter(agentHostLabelFormatter('windows-host', OperatingSystem.Windows)));
72+
const target = toAgentHostUri(URI.file('C:/my project/file.ts'), 'windows-host').toString();
73+
store.add(testRenderer.render(new MarkdownString(`[file](${target})`)));
74+
75+
assert.strictEqual(setupManagedHover.lastCall.args[2], 'C:\\my project\\file.ts');
76+
});
77+
78+
test('preserves explicit titles and ordinary file, external, and command link behavior', () => {
79+
const target = toAgentHostUri(URI.file('/home/user/file.ts'), 'remote-host').toString();
80+
const file = URI.file('/my project/file.ts').with({ fragment: 'L7' });
81+
const markdown = new MarkdownString(`[custom](${target} "Custom title") [file](${file}) [web](https://example.com/) [command](command:example)`, { isTrusted: true });
82+
const result = store.add(testRenderer.render(markdown));
83+
84+
assert.deepStrictEqual({
85+
hovers: setupManagedHover.getCalls().map(call => call.args[2]),
86+
targets: Array.from(result.element.querySelectorAll('a'), link => link.dataset.href),
87+
}, {
88+
hovers: ['Custom title', `${file.fsPath}#L7`, 'https://example.com/'],
89+
targets: [target, file.toString(), 'https://example.com/', 'command:example'],
90+
});
91+
});
92+
});
93+
2294
test('simple', async () => {
2395
const md = new MarkdownString('a');
2496
const result = store.add(testRenderer.render(md));

0 commit comments

Comments
 (0)