Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/links/browser/links.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 { createCancelablePromise, CancelablePromise, RunOnceScheduler } from '../../../../base/common/async.js';
7
import { CancellationToken } from '../../../../base/common/cancellation.js';
8
import { onUnexpectedError } from '../../../../base/common/errors.js';
9
import { MarkdownString } from '../../../../base/common/htmlContent.js';
10
import { Disposable } from '../../../../base/common/lifecycle.js';
11
import { Schemas } from '../../../../base/common/network.js';
12
import * as platform from '../../../../base/common/platform.js';
13
import * as resources from '../../../../base/common/resources.js';
14
import { StopWatch } from '../../../../base/common/stopwatch.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import './links.css';
17
import { ICodeEditor, MouseTargetType } from '../../../browser/editorBrowser.js';
18
import { EditorAction, EditorContributionInstantiation, registerEditorAction, registerEditorContribution, ServicesAccessor } from '../../../browser/editorExtensions.js';
19
import { EditorOption } from '../../../common/config/editorOptions.js';
20
import { Position } from '../../../common/core/position.js';
21
import { IEditorContribution } from '../../../common/editorCommon.js';
22
import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';
23
import { LinkProvider } from '../../../common/languages.js';
24
import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, TrackedRangeStickiness } from '../../../common/model.js';
25
import { ModelDecorationOptions } from '../../../common/model/textModel.js';
26
import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from '../../../common/services/languageFeatureDebounce.js';
27
import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';
28
import { ClickLinkGesture, ClickLinkKeyboardEvent, ClickLinkMouseEvent } from '../../gotoSymbol/browser/link/clickLinkGesture.js';
29
import { getLinks, Link, LinksList } from './getLinks.js';
30
import * as nls from '../../../../nls.js';
31
import { INotificationService } from '../../../../platform/notification/common/notification.js';
32
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
33
34
export class LinkDetector extends Disposable implements IEditorContribution {
35
36
public static readonly ID: string = 'editor.linkDetector';
37
38
public static get(editor: ICodeEditor): LinkDetector | null {
39
return editor.getContribution<LinkDetector>(LinkDetector.ID);
40
}
41
42
private readonly providers: LanguageFeatureRegistry<LinkProvider>;
43
private readonly debounceInformation: IFeatureDebounceInformation;
44
private readonly computeLinks: RunOnceScheduler;
45
private computePromise: CancelablePromise<LinksList> | null;
46
private activeLinksList: LinksList | null;
47
private activeLinkDecorationId: string | null;
48
private currentOccurrences: { [decorationId: string]: LinkOccurrence };
49
50
constructor(
51
private readonly editor: ICodeEditor,
52
@IOpenerService private readonly openerService: IOpenerService,
53
@INotificationService private readonly notificationService: INotificationService,
54
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
55
@ILanguageFeatureDebounceService languageFeatureDebounceService: ILanguageFeatureDebounceService,
56
) {
57
super();
58
59
this.providers = this.languageFeaturesService.linkProvider;
60
this.debounceInformation = languageFeatureDebounceService.for(this.providers, 'Links', { min: 1000, max: 4000 });
61
this.computeLinks = this._register(new RunOnceScheduler(() => this.computeLinksNow(), 1000));
62
this.computePromise = null;
63
this.activeLinksList = null;
64
this.currentOccurrences = {};
65
this.activeLinkDecorationId = null;
66
67
const clickLinkGesture = this._register(new ClickLinkGesture(editor));
68
69
this._register(clickLinkGesture.onMouseMoveOrRelevantKeyDown(([mouseEvent, keyboardEvent]) => {
70
this._onEditorMouseMove(mouseEvent, keyboardEvent);
71
}));
72
this._register(clickLinkGesture.onExecute((e) => {
73
this.onEditorMouseUp(e);
74
}));
75
this._register(clickLinkGesture.onCancel((e) => {
76
this.cleanUpActiveLinkDecoration();
77
}));
78
this._register(editor.onDidChangeConfiguration((e) => {
79
if (!e.hasChanged(EditorOption.links)) {
80
return;
81
}
82
// Remove any links (for the getting disabled case)
83
this.updateDecorations([]);
84
85
// Stop any computation (for the getting disabled case)
86
this.stop();
87
88
// Start computing (for the getting enabled case)
89
this.computeLinks.schedule(0);
90
}));
91
this._register(editor.onDidChangeModelContent((e) => {
92
if (!this.editor.hasModel()) {
93
return;
94
}
95
this.computeLinks.schedule(this.debounceInformation.get(this.editor.getModel()));
96
}));
97
this._register(editor.onDidChangeModel((e) => {
98
this.currentOccurrences = {};
99
this.activeLinkDecorationId = null;
100
this.stop();
101
this.computeLinks.schedule(0);
102
}));
103
this._register(editor.onDidChangeModelLanguage((e) => {
104
this.stop();
105
this.computeLinks.schedule(0);
106
}));
107
this._register(this.providers.onDidChange((e) => {
108
this.stop();
109
this.computeLinks.schedule(0);
110
}));
111
112
this.computeLinks.schedule(0);
113
}
114
115
private async computeLinksNow(): Promise<void> {
116
if (!this.editor.hasModel() || !this.editor.getOption(EditorOption.links)) {
117
return;
118
}
119
120
const model = this.editor.getModel();
121
122
if (model.isTooLargeForSyncing()) {
123
return;
124
}
125
126
if (!this.providers.has(model)) {
127
return;
128
}
129
130
if (this.activeLinksList) {
131
this.activeLinksList.dispose();
132
this.activeLinksList = null;
133
}
134
135
this.computePromise = createCancelablePromise(token => getLinks(this.providers, model, token));
136
try {
137
const sw = new StopWatch(false);
138
this.activeLinksList = await this.computePromise;
139
this.debounceInformation.update(model, sw.elapsed());
140
if (model.isDisposed()) {
141
return;
142
}
143
this.updateDecorations(this.activeLinksList.links);
144
} catch (err) {
145
onUnexpectedError(err);
146
} finally {
147
this.computePromise = null;
148
}
149
}
150
151
private updateDecorations(links: Link[]): void {
152
const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');
153
const oldDecorations: string[] = [];
154
const keys = Object.keys(this.currentOccurrences);
155
for (const decorationId of keys) {
156
const occurence = this.currentOccurrences[decorationId];
157
oldDecorations.push(occurence.decorationId);
158
}
159
160
const newDecorations: IModelDeltaDecoration[] = [];
161
if (links) {
162
// Not sure why this is sometimes null
163
for (const link of links) {
164
newDecorations.push(LinkOccurrence.decoration(link, useMetaKey));
165
}
166
}
167
168
this.editor.changeDecorations((changeAccessor) => {
169
const decorations = changeAccessor.deltaDecorations(oldDecorations, newDecorations);
170
171
this.currentOccurrences = {};
172
this.activeLinkDecorationId = null;
173
for (let i = 0, len = decorations.length; i < len; i++) {
174
const occurence = new LinkOccurrence(links[i], decorations[i]);
175
this.currentOccurrences[occurence.decorationId] = occurence;
176
}
177
});
178
}
179
180
private _onEditorMouseMove(mouseEvent: ClickLinkMouseEvent, withKey: ClickLinkKeyboardEvent | null): void {
181
const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');
182
if (this.isEnabled(mouseEvent, withKey)) {
183
this.cleanUpActiveLinkDecoration(); // always remove previous link decoration as their can only be one
184
const occurrence = this.getLinkOccurrence(mouseEvent.target.position);
185
if (occurrence) {
186
this.editor.changeDecorations((changeAccessor) => {
187
occurrence.activate(changeAccessor, useMetaKey);
188
this.activeLinkDecorationId = occurrence.decorationId;
189
});
190
}
191
} else {
192
this.cleanUpActiveLinkDecoration();
193
}
194
}
195
196
private cleanUpActiveLinkDecoration(): void {
197
const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');
198
if (this.activeLinkDecorationId) {
199
const occurrence = this.currentOccurrences[this.activeLinkDecorationId];
200
if (occurrence) {
201
this.editor.changeDecorations((changeAccessor) => {
202
occurrence.deactivate(changeAccessor, useMetaKey);
203
});
204
}
205
206
this.activeLinkDecorationId = null;
207
}
208
}
209
210
private onEditorMouseUp(mouseEvent: ClickLinkMouseEvent): void {
211
if (!this.isEnabled(mouseEvent)) {
212
return;
213
}
214
const occurrence = this.getLinkOccurrence(mouseEvent.target.position);
215
if (!occurrence) {
216
return;
217
}
218
this.openLinkOccurrence(occurrence, mouseEvent.hasSideBySideModifier, true /* from user gesture */);
219
}
220
221
public openLinkOccurrence(occurrence: LinkOccurrence, openToSide: boolean, fromUserGesture = false): void {
222
223
if (!this.openerService) {
224
return;
225
}
226
227
const { link } = occurrence;
228
229
link.resolve(CancellationToken.None).then(uri => {
230
231
// Support for relative file URIs of the shape file://./relativeFile.txt or file:///./relativeFile.txt
232
if (typeof uri === 'string' && this.editor.hasModel()) {
233
const modelUri = this.editor.getModel().uri;
234
if (modelUri.scheme === Schemas.file && uri.startsWith(`${Schemas.file}:`)) {
235
const parsedUri = URI.parse(uri);
236
if (parsedUri.scheme === Schemas.file) {
237
const fsPath = resources.originalFSPath(parsedUri);
238
239
let relativePath: string | null = null;
240
if (fsPath.startsWith('/./') || fsPath.startsWith('\\.\\')) {
241
relativePath = `.${fsPath.substr(1)}`;
242
} else if (fsPath.startsWith('//./') || fsPath.startsWith('\\\\.\\')) {
243
relativePath = `.${fsPath.substr(2)}`;
244
}
245
246
if (relativePath) {
247
uri = resources.joinPath(modelUri, relativePath);
248
}
249
}
250
}
251
}
252
253
return this.openerService.open(uri, { openToSide, fromUserGesture, allowContributedOpeners: true, allowCommands: true, fromWorkspace: true });
254
255
}, err => {
256
const messageOrError =
257
err instanceof Error ? (<Error>err).message : err;
258
// different error cases
259
if (messageOrError === 'invalid') {
260
this.notificationService.warn(nls.localize('invalid.url', 'Failed to open this link because it is not well-formed: {0}', link.url!.toString()));
261
} else if (messageOrError === 'missing') {
262
this.notificationService.warn(nls.localize('missing.url', 'Failed to open this link because its target is missing.'));
263
} else {
264
onUnexpectedError(err);
265
}
266
});
267
}
268
269
public getLinkOccurrence(position: Position | null): LinkOccurrence | null {
270
if (!this.editor.hasModel() || !position) {
271
return null;
272
}
273
const decorations = this.editor.getModel().getDecorationsInRange({
274
startLineNumber: position.lineNumber,
275
startColumn: position.column,
276
endLineNumber: position.lineNumber,
277
endColumn: position.column
278
}, 0, true);
279
280
for (const decoration of decorations) {
281
const currentOccurrence = this.currentOccurrences[decoration.id];
282
if (currentOccurrence) {
283
return currentOccurrence;
284
}
285
}
286
287
return null;
288
}
289
290
private isEnabled(mouseEvent: ClickLinkMouseEvent, withKey?: ClickLinkKeyboardEvent | null): boolean {
291
return Boolean(
292
(mouseEvent.target.type === MouseTargetType.CONTENT_TEXT)
293
&& (mouseEvent.hasTriggerModifier || (withKey && withKey.keyCodeIsTriggerKey))
294
);
295
}
296
297
private stop(): void {
298
this.computeLinks.cancel();
299
if (this.activeLinksList) {
300
this.activeLinksList?.dispose();
301
this.activeLinksList = null;
302
}
303
if (this.computePromise) {
304
this.computePromise.cancel();
305
this.computePromise = null;
306
}
307
}
308
309
public override dispose(): void {
310
super.dispose();
311
this.stop();
312
}
313
}
314
315
const decoration = {
316
general: ModelDecorationOptions.register({
317
description: 'detected-link',
318
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
319
collapseOnReplaceEdit: true,
320
inlineClassName: 'detected-link'
321
}),
322
active: ModelDecorationOptions.register({
323
description: 'detected-link-active',
324
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
325
collapseOnReplaceEdit: true,
326
inlineClassName: 'detected-link-active'
327
})
328
};
329
330
class LinkOccurrence {
331
332
public static decoration(link: Link, useMetaKey: boolean): IModelDeltaDecoration {
333
return {
334
range: link.range,
335
options: LinkOccurrence._getOptions(link, useMetaKey, false)
336
};
337
}
338
339
private static _getOptions(link: Link, useMetaKey: boolean, isActive: boolean): ModelDecorationOptions {
340
const options = { ... (isActive ? decoration.active : decoration.general) };
341
options.hoverMessage = getHoverMessage(link, useMetaKey);
342
return options;
343
}
344
345
public decorationId: string;
346
public link: Link;
347
348
constructor(link: Link, decorationId: string) {
349
this.link = link;
350
this.decorationId = decorationId;
351
}
352
353
public activate(changeAccessor: IModelDecorationsChangeAccessor, useMetaKey: boolean): void {
354
changeAccessor.changeDecorationOptions(this.decorationId, LinkOccurrence._getOptions(this.link, useMetaKey, true));
355
}
356
357
public deactivate(changeAccessor: IModelDecorationsChangeAccessor, useMetaKey: boolean): void {
358
changeAccessor.changeDecorationOptions(this.decorationId, LinkOccurrence._getOptions(this.link, useMetaKey, false));
359
}
360
}
361
362
function getHoverMessage(link: Link, useMetaKey: boolean): MarkdownString {
363
const executeCmd = link.url && /^command:/i.test(link.url.toString());
364
365
const label = link.tooltip
366
? link.tooltip
367
: executeCmd
368
? nls.localize('links.navigate.executeCmd', 'Execute command')
369
: nls.localize('links.navigate.follow', 'Follow link');
370
371
const kb = useMetaKey
372
? platform.isMacintosh
373
? nls.localize('links.navigate.kb.meta.mac', "cmd + click")
374
: nls.localize('links.navigate.kb.meta', "ctrl + click")
375
: platform.isMacintosh
376
? nls.localize('links.navigate.kb.alt.mac', "option + click")
377
: nls.localize('links.navigate.kb.alt', "alt + click");
378
379
if (link.url) {
380
let nativeLabel = '';
381
if (/^command:/i.test(link.url.toString())) {
382
// Don't show complete command arguments in the native tooltip
383
const match = link.url.toString().match(/^command:([^?#]+)/);
384
if (match) {
385
const commandId = match[1];
386
nativeLabel = nls.localize('tooltip.explanation', "Execute command {0}", commandId);
387
}
388
}
389
const hoverMessage = new MarkdownString('', true)
390
.appendLink(link.url.toString(true).replace(/ /g, '%20'), label, nativeLabel)
391
.appendMarkdown(` (${kb})`);
392
return hoverMessage;
393
} else {
394
return new MarkdownString().appendText(`${label} (${kb})`);
395
}
396
}
397
398
class OpenLinkAction extends EditorAction {
399
400
constructor() {
401
super({
402
id: 'editor.action.openLink',
403
label: nls.localize2('label', "Open Link"),
404
precondition: undefined
405
});
406
}
407
408
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
409
const linkDetector = LinkDetector.get(editor);
410
if (!linkDetector) {
411
return;
412
}
413
if (!editor.hasModel()) {
414
return;
415
}
416
417
const selections = editor.getSelections();
418
for (const sel of selections) {
419
const link = linkDetector.getLinkOccurrence(sel.getEndPosition());
420
if (link) {
421
linkDetector.openLinkOccurrence(link, false);
422
}
423
}
424
}
425
}
426
427
registerEditorContribution(LinkDetector.ID, LinkDetector, EditorContributionInstantiation.AfterFirstRender);
428
registerEditorAction(OpenLinkAction);
429
430