Path: blob/main/src/vs/editor/contrib/links/browser/links.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { createCancelablePromise, CancelablePromise, RunOnceScheduler } from '../../../../base/common/async.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { onUnexpectedError } from '../../../../base/common/errors.js';8import { MarkdownString } from '../../../../base/common/htmlContent.js';9import { Disposable } from '../../../../base/common/lifecycle.js';10import { Schemas } from '../../../../base/common/network.js';11import * as platform from '../../../../base/common/platform.js';12import * as resources from '../../../../base/common/resources.js';13import { StopWatch } from '../../../../base/common/stopwatch.js';14import { URI } from '../../../../base/common/uri.js';15import './links.css';16import { ICodeEditor, MouseTargetType } from '../../../browser/editorBrowser.js';17import { EditorAction, EditorContributionInstantiation, registerEditorAction, registerEditorContribution, ServicesAccessor } from '../../../browser/editorExtensions.js';18import { EditorOption } from '../../../common/config/editorOptions.js';19import { Position } from '../../../common/core/position.js';20import { IEditorContribution } from '../../../common/editorCommon.js';21import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';22import { LinkProvider } from '../../../common/languages.js';23import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, TrackedRangeStickiness } from '../../../common/model.js';24import { ModelDecorationOptions } from '../../../common/model/textModel.js';25import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from '../../../common/services/languageFeatureDebounce.js';26import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';27import { ClickLinkGesture, ClickLinkKeyboardEvent, ClickLinkMouseEvent } from '../../gotoSymbol/browser/link/clickLinkGesture.js';28import { getLinks, Link, LinksList } from './getLinks.js';29import * as nls from '../../../../nls.js';30import { INotificationService } from '../../../../platform/notification/common/notification.js';31import { IOpenerService } from '../../../../platform/opener/common/opener.js';3233export class LinkDetector extends Disposable implements IEditorContribution {3435public static readonly ID: string = 'editor.linkDetector';3637public static get(editor: ICodeEditor): LinkDetector | null {38return editor.getContribution<LinkDetector>(LinkDetector.ID);39}4041private readonly providers: LanguageFeatureRegistry<LinkProvider>;42private readonly debounceInformation: IFeatureDebounceInformation;43private readonly computeLinks: RunOnceScheduler;44private computePromise: CancelablePromise<LinksList> | null;45private activeLinksList: LinksList | null;46private activeLinkDecorationId: string | null;47private currentOccurrences: { [decorationId: string]: LinkOccurrence };4849constructor(50private readonly editor: ICodeEditor,51@IOpenerService private readonly openerService: IOpenerService,52@INotificationService private readonly notificationService: INotificationService,53@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,54@ILanguageFeatureDebounceService languageFeatureDebounceService: ILanguageFeatureDebounceService,55) {56super();5758this.providers = this.languageFeaturesService.linkProvider;59this.debounceInformation = languageFeatureDebounceService.for(this.providers, 'Links', { min: 1000, max: 4000 });60this.computeLinks = this._register(new RunOnceScheduler(() => this.computeLinksNow(), 1000));61this.computePromise = null;62this.activeLinksList = null;63this.currentOccurrences = {};64this.activeLinkDecorationId = null;6566const clickLinkGesture = this._register(new ClickLinkGesture(editor));6768this._register(clickLinkGesture.onMouseMoveOrRelevantKeyDown(([mouseEvent, keyboardEvent]) => {69this._onEditorMouseMove(mouseEvent, keyboardEvent);70}));71this._register(clickLinkGesture.onExecute((e) => {72this.onEditorMouseUp(e);73}));74this._register(clickLinkGesture.onCancel((e) => {75this.cleanUpActiveLinkDecoration();76}));77this._register(editor.onDidChangeConfiguration((e) => {78if (!e.hasChanged(EditorOption.links)) {79return;80}81// Remove any links (for the getting disabled case)82this.updateDecorations([]);8384// Stop any computation (for the getting disabled case)85this.stop();8687// Start computing (for the getting enabled case)88this.computeLinks.schedule(0);89}));90this._register(editor.onDidChangeModelContent((e) => {91if (!this.editor.hasModel()) {92return;93}94this.computeLinks.schedule(this.debounceInformation.get(this.editor.getModel()));95}));96this._register(editor.onDidChangeModel((e) => {97this.currentOccurrences = {};98this.activeLinkDecorationId = null;99this.stop();100this.computeLinks.schedule(0);101}));102this._register(editor.onDidChangeModelLanguage((e) => {103this.stop();104this.computeLinks.schedule(0);105}));106this._register(this.providers.onDidChange((e) => {107this.stop();108this.computeLinks.schedule(0);109}));110111this.computeLinks.schedule(0);112}113114private async computeLinksNow(): Promise<void> {115if (!this.editor.hasModel() || !this.editor.getOption(EditorOption.links)) {116return;117}118119const model = this.editor.getModel();120121if (model.isTooLargeForSyncing()) {122return;123}124125if (!this.providers.has(model)) {126return;127}128129if (this.activeLinksList) {130this.activeLinksList.dispose();131this.activeLinksList = null;132}133134this.computePromise = createCancelablePromise(token => getLinks(this.providers, model, token));135try {136const sw = new StopWatch(false);137this.activeLinksList = await this.computePromise;138this.debounceInformation.update(model, sw.elapsed());139if (model.isDisposed()) {140return;141}142this.updateDecorations(this.activeLinksList.links);143} catch (err) {144onUnexpectedError(err);145} finally {146this.computePromise = null;147}148}149150private updateDecorations(links: Link[]): void {151const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');152const oldDecorations: string[] = [];153const keys = Object.keys(this.currentOccurrences);154for (const decorationId of keys) {155const occurence = this.currentOccurrences[decorationId];156oldDecorations.push(occurence.decorationId);157}158159const newDecorations: IModelDeltaDecoration[] = [];160if (links) {161// Not sure why this is sometimes null162for (const link of links) {163newDecorations.push(LinkOccurrence.decoration(link, useMetaKey));164}165}166167this.editor.changeDecorations((changeAccessor) => {168const decorations = changeAccessor.deltaDecorations(oldDecorations, newDecorations);169170this.currentOccurrences = {};171this.activeLinkDecorationId = null;172for (let i = 0, len = decorations.length; i < len; i++) {173const occurence = new LinkOccurrence(links[i], decorations[i]);174this.currentOccurrences[occurence.decorationId] = occurence;175}176});177}178179private _onEditorMouseMove(mouseEvent: ClickLinkMouseEvent, withKey: ClickLinkKeyboardEvent | null): void {180const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');181if (this.isEnabled(mouseEvent, withKey)) {182this.cleanUpActiveLinkDecoration(); // always remove previous link decoration as their can only be one183const occurrence = this.getLinkOccurrence(mouseEvent.target.position);184if (occurrence) {185this.editor.changeDecorations((changeAccessor) => {186occurrence.activate(changeAccessor, useMetaKey);187this.activeLinkDecorationId = occurrence.decorationId;188});189}190} else {191this.cleanUpActiveLinkDecoration();192}193}194195private cleanUpActiveLinkDecoration(): void {196const useMetaKey = (this.editor.getOption(EditorOption.multiCursorModifier) === 'altKey');197if (this.activeLinkDecorationId) {198const occurrence = this.currentOccurrences[this.activeLinkDecorationId];199if (occurrence) {200this.editor.changeDecorations((changeAccessor) => {201occurrence.deactivate(changeAccessor, useMetaKey);202});203}204205this.activeLinkDecorationId = null;206}207}208209private onEditorMouseUp(mouseEvent: ClickLinkMouseEvent): void {210if (!this.isEnabled(mouseEvent)) {211return;212}213const occurrence = this.getLinkOccurrence(mouseEvent.target.position);214if (!occurrence) {215return;216}217this.openLinkOccurrence(occurrence, mouseEvent.hasSideBySideModifier, true /* from user gesture */);218}219220public openLinkOccurrence(occurrence: LinkOccurrence, openToSide: boolean, fromUserGesture = false): void {221222if (!this.openerService) {223return;224}225226const { link } = occurrence;227228link.resolve(CancellationToken.None).then(uri => {229230// Support for relative file URIs of the shape file://./relativeFile.txt or file:///./relativeFile.txt231if (typeof uri === 'string' && this.editor.hasModel()) {232const modelUri = this.editor.getModel().uri;233if (modelUri.scheme === Schemas.file && uri.startsWith(`${Schemas.file}:`)) {234const parsedUri = URI.parse(uri);235if (parsedUri.scheme === Schemas.file) {236const fsPath = resources.originalFSPath(parsedUri);237238let relativePath: string | null = null;239if (fsPath.startsWith('/./') || fsPath.startsWith('\\.\\')) {240relativePath = `.${fsPath.substr(1)}`;241} else if (fsPath.startsWith('//./') || fsPath.startsWith('\\\\.\\')) {242relativePath = `.${fsPath.substr(2)}`;243}244245if (relativePath) {246uri = resources.joinPath(modelUri, relativePath);247}248}249}250}251252return this.openerService.open(uri, { openToSide, fromUserGesture, allowContributedOpeners: true, allowCommands: true, fromWorkspace: true });253254}, err => {255const messageOrError =256err instanceof Error ? (<Error>err).message : err;257// different error cases258if (messageOrError === 'invalid') {259this.notificationService.warn(nls.localize('invalid.url', 'Failed to open this link because it is not well-formed: {0}', link.url!.toString()));260} else if (messageOrError === 'missing') {261this.notificationService.warn(nls.localize('missing.url', 'Failed to open this link because its target is missing.'));262} else {263onUnexpectedError(err);264}265});266}267268public getLinkOccurrence(position: Position | null): LinkOccurrence | null {269if (!this.editor.hasModel() || !position) {270return null;271}272const decorations = this.editor.getModel().getDecorationsInRange({273startLineNumber: position.lineNumber,274startColumn: position.column,275endLineNumber: position.lineNumber,276endColumn: position.column277}, 0, true);278279for (const decoration of decorations) {280const currentOccurrence = this.currentOccurrences[decoration.id];281if (currentOccurrence) {282return currentOccurrence;283}284}285286return null;287}288289private isEnabled(mouseEvent: ClickLinkMouseEvent, withKey?: ClickLinkKeyboardEvent | null): boolean {290return Boolean(291(mouseEvent.target.type === MouseTargetType.CONTENT_TEXT)292&& (mouseEvent.hasTriggerModifier || (withKey && withKey.keyCodeIsTriggerKey))293);294}295296private stop(): void {297this.computeLinks.cancel();298if (this.activeLinksList) {299this.activeLinksList?.dispose();300this.activeLinksList = null;301}302if (this.computePromise) {303this.computePromise.cancel();304this.computePromise = null;305}306}307308public override dispose(): void {309super.dispose();310this.stop();311}312}313314const decoration = {315general: ModelDecorationOptions.register({316description: 'detected-link',317stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,318collapseOnReplaceEdit: true,319inlineClassName: 'detected-link'320}),321active: ModelDecorationOptions.register({322description: 'detected-link-active',323stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,324collapseOnReplaceEdit: true,325inlineClassName: 'detected-link-active'326})327};328329class LinkOccurrence {330331public static decoration(link: Link, useMetaKey: boolean): IModelDeltaDecoration {332return {333range: link.range,334options: LinkOccurrence._getOptions(link, useMetaKey, false)335};336}337338private static _getOptions(link: Link, useMetaKey: boolean, isActive: boolean): ModelDecorationOptions {339const options = { ... (isActive ? decoration.active : decoration.general) };340options.hoverMessage = getHoverMessage(link, useMetaKey);341return options;342}343344public decorationId: string;345public link: Link;346347constructor(link: Link, decorationId: string) {348this.link = link;349this.decorationId = decorationId;350}351352public activate(changeAccessor: IModelDecorationsChangeAccessor, useMetaKey: boolean): void {353changeAccessor.changeDecorationOptions(this.decorationId, LinkOccurrence._getOptions(this.link, useMetaKey, true));354}355356public deactivate(changeAccessor: IModelDecorationsChangeAccessor, useMetaKey: boolean): void {357changeAccessor.changeDecorationOptions(this.decorationId, LinkOccurrence._getOptions(this.link, useMetaKey, false));358}359}360361function getHoverMessage(link: Link, useMetaKey: boolean): MarkdownString {362const executeCmd = link.url && /^command:/i.test(link.url.toString());363364const label = link.tooltip365? link.tooltip366: executeCmd367? nls.localize('links.navigate.executeCmd', 'Execute command')368: nls.localize('links.navigate.follow', 'Follow link');369370const kb = useMetaKey371? platform.isMacintosh372? nls.localize('links.navigate.kb.meta.mac', "cmd + click")373: nls.localize('links.navigate.kb.meta', "ctrl + click")374: platform.isMacintosh375? nls.localize('links.navigate.kb.alt.mac', "option + click")376: nls.localize('links.navigate.kb.alt', "alt + click");377378if (link.url) {379let nativeLabel = '';380if (/^command:/i.test(link.url.toString())) {381// Don't show complete command arguments in the native tooltip382const match = link.url.toString().match(/^command:([^?#]+)/);383if (match) {384const commandId = match[1];385nativeLabel = nls.localize('tooltip.explanation', "Execute command {0}", commandId);386}387}388const hoverMessage = new MarkdownString('', true)389.appendLink(link.url.toString(true).replace(/ /g, '%20'), label, nativeLabel)390.appendMarkdown(` (${kb})`);391return hoverMessage;392} else {393return new MarkdownString().appendText(`${label} (${kb})`);394}395}396397class OpenLinkAction extends EditorAction {398399constructor() {400super({401id: 'editor.action.openLink',402label: nls.localize2('label', "Open Link"),403precondition: undefined404});405}406407public run(accessor: ServicesAccessor, editor: ICodeEditor): void {408const linkDetector = LinkDetector.get(editor);409if (!linkDetector) {410return;411}412if (!editor.hasModel()) {413return;414}415416const selections = editor.getSelections();417for (const sel of selections) {418const link = linkDetector.getLinkOccurrence(sel.getEndPosition());419if (link) {420linkDetector.openLinkOccurrence(link, false);421}422}423}424}425426registerEditorContribution(LinkDetector.ID, LinkDetector, EditorContributionInstantiation.AfterFirstRender);427registerEditorAction(OpenLinkAction);428429430