Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/debug/browser/linkDetector.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { getWindow, isHTMLElement, reset } from '../../../../base/browser/dom.js';
7
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
8
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
9
import { KeyCode } from '../../../../base/common/keyCodes.js';
10
import { DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { Schemas } from '../../../../base/common/network.js';
12
import * as osPath from '../../../../base/common/path.js';
13
import * as platform from '../../../../base/common/platform.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { localize } from '../../../../nls.js';
16
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
17
import { IFileService } from '../../../../platform/files/common/files.js';
18
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
19
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
20
import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js';
21
import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';
22
import { IDebugSession } from '../common/debug.js';
23
import { IEditorService } from '../../../services/editor/common/editorService.js';
24
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
25
import { IPathService } from '../../../services/path/common/pathService.js';
26
import { IHighlight } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js';
27
import { Iterable } from '../../../../base/common/iterator.js';
28
29
const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';
30
const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');
31
32
const WIN_ABSOLUTE_PATH = /(?:[a-zA-Z]:(?:(?:\\|\/)[\w\.-]*)+)/;
33
const WIN_RELATIVE_PATH = /(?:(?:\~|\.+)(?:(?:\\|\/)[\w\.-]*)+)/;
34
const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);
35
const POSIX_PATH = /((?:\~|\.+)?(?:\/[\w\.-]*)+)/;
36
const LINE_COLUMN = /(?:\:([\d]+))?(?:\:([\d]+))?/;
37
const PATH_LINK_REGEX = new RegExp(`${platform.isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');
38
const LINE_COLUMN_REGEX = /:([\d]+)(?::([\d]+))?$/;
39
40
const MAX_LENGTH = 2000;
41
42
type LinkKind = 'web' | 'path' | 'text';
43
type LinkPart = {
44
kind: LinkKind;
45
value: string;
46
captures: string[];
47
index: number;
48
};
49
50
export const enum DebugLinkHoverBehavior {
51
/** A nice workbench hover */
52
Rich,
53
/**
54
* Basic browser hover
55
* @deprecated Consumers should adopt `rich` by propagating disposables appropriately
56
*/
57
Basic,
58
/** No hover */
59
None
60
}
61
62
/** Store implies HoverBehavior=rich */
63
export type DebugLinkHoverBehaviorTypeData = { type: DebugLinkHoverBehavior.None | DebugLinkHoverBehavior.Basic }
64
| { type: DebugLinkHoverBehavior.Rich; store: DisposableStore };
65
66
export interface ILinkDetector {
67
linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement;
68
linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior?: DebugLinkHoverBehaviorTypeData): HTMLElement;
69
}
70
71
export class LinkDetector implements ILinkDetector {
72
constructor(
73
@IEditorService private readonly editorService: IEditorService,
74
@IFileService private readonly fileService: IFileService,
75
@IOpenerService private readonly openerService: IOpenerService,
76
@IPathService private readonly pathService: IPathService,
77
@ITunnelService private readonly tunnelService: ITunnelService,
78
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
79
@IConfigurationService private readonly configurationService: IConfigurationService,
80
@IHoverService private readonly hoverService: IHoverService,
81
) {
82
// noop
83
}
84
85
/**
86
* Matches and handles web urls, absolute and relative file links in the string provided.
87
* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/>.
88
* 'onclick' event is attached to all anchored links that opens them in the editor.
89
* When splitLines is true, each line of the text, even if it contains no links, is wrapped in a <span>
90
* and added as a child of the returned <span>.
91
* If a `hoverBehavior` is passed, hovers may be added using the workbench hover service.
92
* This should be preferred for new code where hovers are desirable.
93
*/
94
linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement {
95
return this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights);
96
}
97
98
private _linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[], defaultRef?: { locationReference: number; session: IDebugSession }): HTMLElement {
99
if (splitLines) {
100
const lines = text.split('\n');
101
for (let i = 0; i < lines.length - 1; i++) {
102
lines[i] = lines[i] + '\n';
103
}
104
if (!lines[lines.length - 1]) {
105
// Remove the last element ('') that split added.
106
lines.pop();
107
}
108
const elements = lines.map(line => this._linkify(line, false, workspaceFolder, includeFulltext, hoverBehavior, highlights, defaultRef));
109
if (elements.length === 1) {
110
// Do not wrap single line with extra span.
111
return elements[0];
112
}
113
const container = document.createElement('span');
114
elements.forEach(e => container.appendChild(e));
115
return container;
116
}
117
118
const container = document.createElement('span');
119
for (const part of this.detectLinks(text)) {
120
try {
121
let node: Node;
122
switch (part.kind) {
123
case 'text':
124
node = defaultRef ? this.linkifyLocation(part.value, defaultRef.locationReference, defaultRef.session, hoverBehavior) : document.createTextNode(part.value);
125
break;
126
case 'web':
127
node = this.createWebLink(includeFulltext ? text : undefined, part.value, hoverBehavior);
128
break;
129
case 'path': {
130
const path = part.captures[0];
131
const lineNumber = part.captures[1] ? Number(part.captures[1]) : 0;
132
const columnNumber = part.captures[2] ? Number(part.captures[2]) : 0;
133
node = this.createPathLink(includeFulltext ? text : undefined, part.value, path, lineNumber, columnNumber, workspaceFolder, hoverBehavior);
134
break;
135
}
136
default:
137
node = document.createTextNode(part.value);
138
}
139
140
container.append(...this.applyHighlights(node, part.index, part.value.length, highlights));
141
} catch (e) {
142
container.appendChild(document.createTextNode(part.value));
143
}
144
}
145
return container;
146
}
147
148
private applyHighlights(node: Node, startIndex: number, length: number, highlights: IHighlight[] | undefined): Iterable<Node | string> {
149
const children: (Node | string)[] = [];
150
let currentIndex = startIndex;
151
const endIndex = startIndex + length;
152
153
for (const highlight of highlights || []) {
154
if (highlight.end <= currentIndex || highlight.start >= endIndex) {
155
continue;
156
}
157
158
if (highlight.start > currentIndex) {
159
children.push(node.textContent!.substring(currentIndex - startIndex, highlight.start - startIndex));
160
currentIndex = highlight.start;
161
}
162
163
const highlightEnd = Math.min(highlight.end, endIndex);
164
const highlightedText = node.textContent!.substring(currentIndex - startIndex, highlightEnd - startIndex);
165
const highlightSpan = document.createElement('span');
166
highlightSpan.classList.add('highlight');
167
if (highlight.extraClasses) {
168
highlightSpan.classList.add(...highlight.extraClasses);
169
}
170
highlightSpan.textContent = highlightedText;
171
children.push(highlightSpan);
172
currentIndex = highlightEnd;
173
}
174
175
if (currentIndex === startIndex) {
176
return Iterable.single(node); // no changes made
177
}
178
179
if (currentIndex < endIndex) {
180
children.push(node.textContent!.substring(currentIndex - startIndex));
181
}
182
183
// reuse the element if it's a link
184
if (isHTMLElement(node)) {
185
reset(node, ...children);
186
return Iterable.single(node);
187
}
188
189
return children;
190
}
191
192
/**
193
* Linkifies a location reference.
194
*/
195
linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior?: DebugLinkHoverBehaviorTypeData) {
196
const link = this.createLink(text);
197
this.decorateLink(link, undefined, text, hoverBehavior, async (preserveFocus: boolean) => {
198
const location = await session.resolveLocationReference(locationReference);
199
await location.source.openInEditor(this.editorService, {
200
startLineNumber: location.line,
201
startColumn: location.column,
202
endLineNumber: location.endLine ?? location.line,
203
endColumn: location.endColumn ?? location.column,
204
}, preserveFocus);
205
});
206
207
return link;
208
}
209
210
/**
211
* Makes an {@link ILinkDetector} that links everything in the output to the
212
* reference if they don't have other explicit links.
213
*/
214
makeReferencedLinkDetector(locationReference: number, session: IDebugSession): ILinkDetector {
215
return {
216
linkify: (text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights) =>
217
this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights, { locationReference, session }),
218
linkifyLocation: this.linkifyLocation.bind(this),
219
};
220
}
221
222
private createWebLink(fulltext: string | undefined, url: string, hoverBehavior?: DebugLinkHoverBehaviorTypeData): Node {
223
const link = this.createLink(url);
224
225
let uri = URI.parse(url);
226
// if the URI ends with something like `foo.js:12:3`, parse
227
// that into a fragment to reveal that location (#150702)
228
const lineCol = LINE_COLUMN_REGEX.exec(uri.path);
229
if (lineCol) {
230
uri = uri.with({
231
path: uri.path.slice(0, lineCol.index),
232
fragment: `L${lineCol[0].slice(1)}`
233
});
234
}
235
236
this.decorateLink(link, uri, fulltext, hoverBehavior, async () => {
237
238
if (uri.scheme === Schemas.file) {
239
// Just using fsPath here is unsafe: https://github.com/microsoft/vscode/issues/109076
240
const fsPath = uri.fsPath;
241
const path = await this.pathService.path;
242
const fileUrl = osPath.normalize(((path.sep === osPath.posix.sep) && platform.isWindows) ? fsPath.replace(/\\/g, osPath.posix.sep) : fsPath);
243
244
const fileUri = URI.parse(fileUrl);
245
const exists = await this.fileService.exists(fileUri);
246
if (!exists) {
247
return;
248
}
249
250
await this.editorService.openEditor({
251
resource: fileUri,
252
options: {
253
pinned: true,
254
selection: lineCol ? { startLineNumber: +lineCol[1], startColumn: +lineCol[2] } : undefined,
255
},
256
});
257
return;
258
}
259
260
this.openerService.open(url, { allowTunneling: (!!this.environmentService.remoteAuthority && this.configurationService.getValue('remote.forwardOnOpen')) });
261
});
262
263
return link;
264
}
265
266
private createPathLink(fulltext: string | undefined, text: string, path: string, lineNumber: number, columnNumber: number, workspaceFolder: IWorkspaceFolder | undefined, hoverBehavior?: DebugLinkHoverBehaviorTypeData): Node {
267
if (path[0] === '/' && path[1] === '/') {
268
// Most likely a url part which did not match, for example ftp://path.
269
return document.createTextNode(text);
270
}
271
272
const options = { selection: { startLineNumber: lineNumber, startColumn: columnNumber } };
273
if (path[0] === '.') {
274
if (!workspaceFolder) {
275
return document.createTextNode(text);
276
}
277
const uri = workspaceFolder.toResource(path);
278
const link = this.createLink(text);
279
this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));
280
return link;
281
}
282
283
if (path[0] === '~') {
284
const userHome = this.pathService.resolvedUserHome;
285
if (userHome) {
286
path = osPath.join(userHome.fsPath, path.substring(1));
287
}
288
}
289
290
const link = this.createLink(text);
291
link.tabIndex = 0;
292
const uri = URI.file(osPath.normalize(path));
293
this.fileService.stat(uri).then(stat => {
294
if (stat.isDirectory) {
295
return;
296
}
297
this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));
298
}).catch(() => {
299
// If the uri can not be resolved we should not spam the console with error, remain quite #86587
300
});
301
return link;
302
}
303
304
private createLink(text: string): HTMLElement {
305
const link = document.createElement('a');
306
link.textContent = text;
307
return link;
308
}
309
310
private decorateLink(link: HTMLElement, uri: URI | undefined, fulltext: string | undefined, hoverBehavior: DebugLinkHoverBehaviorTypeData | undefined, onClick: (preserveFocus: boolean) => void) {
311
link.classList.add('link');
312
const followLink = uri && this.tunnelService.canTunnel(uri) ? localize('followForwardedLink', "follow link using forwarded port") : localize('followLink', "follow link");
313
const title = link.ariaLabel = fulltext
314
? (platform.isMacintosh ? localize('fileLinkWithPathMac', "Cmd + click to {0}\n{1}", followLink, fulltext) : localize('fileLinkWithPath', "Ctrl + click to {0}\n{1}", followLink, fulltext))
315
: (platform.isMacintosh ? localize('fileLinkMac', "Cmd + click to {0}", followLink) : localize('fileLink', "Ctrl + click to {0}", followLink));
316
317
if (hoverBehavior?.type === DebugLinkHoverBehavior.Rich) {
318
hoverBehavior.store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, title));
319
} else if (hoverBehavior?.type !== DebugLinkHoverBehavior.None) {
320
link.title = title;
321
}
322
323
link.onmousemove = (event) => { link.classList.toggle('pointer', platform.isMacintosh ? event.metaKey : event.ctrlKey); };
324
link.onmouseleave = () => link.classList.remove('pointer');
325
link.onclick = (event) => {
326
const selection = getWindow(link).getSelection();
327
if (!selection || selection.type === 'Range') {
328
return; // do not navigate when user is selecting
329
}
330
if (!(platform.isMacintosh ? event.metaKey : event.ctrlKey)) {
331
return;
332
}
333
334
event.preventDefault();
335
event.stopImmediatePropagation();
336
onClick(false);
337
};
338
link.onkeydown = e => {
339
const event = new StandardKeyboardEvent(e);
340
if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {
341
event.preventDefault();
342
event.stopPropagation();
343
onClick(event.keyCode === KeyCode.Space);
344
}
345
};
346
}
347
348
private detectLinks(text: string): LinkPart[] {
349
if (text.length > MAX_LENGTH) {
350
return [{ kind: 'text', value: text, captures: [], index: 0 }];
351
}
352
353
const regexes: RegExp[] = [WEB_LINK_REGEX, PATH_LINK_REGEX];
354
const kinds: LinkKind[] = ['web', 'path'];
355
const result: LinkPart[] = [];
356
357
const splitOne = (text: string, regexIndex: number, baseIndex: number) => {
358
if (regexIndex >= regexes.length) {
359
result.push({ value: text, kind: 'text', captures: [], index: baseIndex });
360
return;
361
}
362
const regex = regexes[regexIndex];
363
let currentIndex = 0;
364
let match;
365
regex.lastIndex = 0;
366
while ((match = regex.exec(text)) !== null) {
367
const stringBeforeMatch = text.substring(currentIndex, match.index);
368
if (stringBeforeMatch) {
369
splitOne(stringBeforeMatch, regexIndex + 1, baseIndex + currentIndex);
370
}
371
const value = match[0];
372
result.push({
373
value: value,
374
kind: kinds[regexIndex],
375
captures: match.slice(1),
376
index: baseIndex + match.index
377
});
378
currentIndex = match.index + value.length;
379
}
380
const stringAfterMatches = text.substring(currentIndex);
381
if (stringAfterMatches) {
382
splitOne(stringAfterMatches, regexIndex + 1, baseIndex + currentIndex);
383
}
384
};
385
386
splitOne(text, 0, 0);
387
return result;
388
}
389
}
390
391