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