Path: blob/main/src/vs/workbench/contrib/debug/browser/linkDetector.ts
5240 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 { addDisposableListener, getWindow, isHTMLElement, reset } from '../../../../base/browser/dom.js';6import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';7import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';8import { KeyCode } from '../../../../base/common/keyCodes.js';9import { DisposableStore } from '../../../../base/common/lifecycle.js';10import { Schemas } from '../../../../base/common/network.js';11import * as osPath from '../../../../base/common/path.js';12import * as platform from '../../../../base/common/platform.js';13import { URI } from '../../../../base/common/uri.js';14import { localize } from '../../../../nls.js';15import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';16import { IFileService } from '../../../../platform/files/common/files.js';17import { IHoverService } from '../../../../platform/hover/browser/hover.js';18import { IOpenerService } from '../../../../platform/opener/common/opener.js';19import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js';20import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';21import { IDebugSession } from '../common/debug.js';22import { IEditorService } from '../../../services/editor/common/editorService.js';23import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';24import { IPathService } from '../../../services/path/common/pathService.js';25import { IHighlight } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js';26import { Iterable } from '../../../../base/common/iterator.js';2728const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';29const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');3031const WIN_ABSOLUTE_PATH = /(?:[a-zA-Z]:(?:(?:\\|\/)[\w\s\.@\-\(\)\[\]{}!#$%^&'`~+=]+)+)/;32const WIN_RELATIVE_PATH = /(?:(?:\~|\.+)(?:(?:\\|\/)[\w\s\.@\-\(\)\[\]{}!#$%^&'`~+=]+)+)/;33const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);34const POSIX_PATH = /((?:\~|\.+)?(?:\/[\w\s\.@\-\(\)\[\]{}!#$%^&'`~+=]+)+)/;35// Support both ":line 123" and ":123:45" formats for line/column numbers36const LINE_COLUMN = /(?::(?:line\s+)?([\d]+))?(?::([\d]+))?/;37const PATH_LINK_REGEX = new RegExp(`${platform.isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');38const LINE_COLUMN_REGEX = /:(?:line\s+)?([\d]+)(?::([\d]+))?$/;3940const MAX_LENGTH = 2000;4142type LinkKind = 'web' | 'path' | 'text';43type LinkPart = {44kind: LinkKind;45value: string;46captures: string[];47index: number;48};4950export const enum DebugLinkHoverBehavior {51/** A nice workbench hover */52Rich,53/**54* Basic browser hover55* @deprecated Consumers should adopt `rich` by propagating disposables appropriately56*/57Basic,58/** No hover */59None60}6162/** Store implies HoverBehavior=rich */63export type DebugLinkHoverBehaviorTypeData =64| { type: DebugLinkHoverBehavior.None; store: DisposableStore }65| { type: DebugLinkHoverBehavior.Basic; store: DisposableStore }66| { type: DebugLinkHoverBehavior.Rich; store: DisposableStore };67686970export interface ILinkDetector {71linkify(text: string, hoverBehavior: DebugLinkHoverBehaviorTypeData, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, highlights?: IHighlight[]): HTMLElement;72linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior: DebugLinkHoverBehaviorTypeData): HTMLElement;73}7475export class LinkDetector implements ILinkDetector {76constructor(77@IEditorService private readonly editorService: IEditorService,78@IFileService private readonly fileService: IFileService,79@IOpenerService private readonly openerService: IOpenerService,80@IPathService private readonly pathService: IPathService,81@ITunnelService private readonly tunnelService: ITunnelService,82@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,83@IConfigurationService private readonly configurationService: IConfigurationService,84@IHoverService private readonly hoverService: IHoverService,85) {86// noop87}8889/**90* Matches and handles web urls, absolute and relative file links in the string provided.91* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/>.92* 'onclick' event is attached to all anchored links that opens them in the editor.93* When splitLines is true, each line of the text, even if it contains no links, is wrapped in a <span>94* and added as a child of the returned <span>.95* The `hoverBehavior` is required and manages the lifecycle of event listeners.96*/97linkify(text: string, hoverBehavior: DebugLinkHoverBehaviorTypeData, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, highlights?: IHighlight[]): HTMLElement {98return this._linkify(text, hoverBehavior, splitLines, workspaceFolder, includeFulltext, highlights);99}100101private _linkify(text: string, hoverBehavior: DebugLinkHoverBehaviorTypeData, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, highlights?: IHighlight[], defaultRef?: { locationReference: number; session: IDebugSession }): HTMLElement {102if (splitLines) {103const lines = text.split('\n');104for (let i = 0; i < lines.length - 1; i++) {105lines[i] = lines[i] + '\n';106}107if (!lines[lines.length - 1]) {108// Remove the last element ('') that split added.109lines.pop();110}111const elements = lines.map(line => this._linkify(line, hoverBehavior, false, workspaceFolder, includeFulltext, highlights, defaultRef));112if (elements.length === 1) {113// Do not wrap single line with extra span.114return elements[0];115}116const container = document.createElement('span');117elements.forEach(e => container.appendChild(e));118return container;119}120121const container = document.createElement('span');122for (const part of this.detectLinks(text)) {123try {124let node: Node;125switch (part.kind) {126case 'text':127node = defaultRef ? this.linkifyLocation(part.value, defaultRef.locationReference, defaultRef.session, hoverBehavior) : document.createTextNode(part.value);128break;129case 'web':130node = this.createWebLink(includeFulltext ? text : undefined, part.value, hoverBehavior);131break;132case 'path': {133const path = part.captures[0];134const lineNumber = part.captures[1] ? Number(part.captures[1]) : 0;135const columnNumber = part.captures[2] ? Number(part.captures[2]) : 0;136node = this.createPathLink(includeFulltext ? text : undefined, part.value, path, lineNumber, columnNumber, workspaceFolder, hoverBehavior);137break;138}139default:140node = document.createTextNode(part.value);141}142143container.append(...this.applyHighlights(node, part.index, part.value.length, highlights));144} catch (e) {145container.appendChild(document.createTextNode(part.value));146}147}148return container;149}150151private applyHighlights(node: Node, startIndex: number, length: number, highlights: IHighlight[] | undefined): Iterable<Node | string> {152const children: (Node | string)[] = [];153let currentIndex = startIndex;154const endIndex = startIndex + length;155156for (const highlight of highlights || []) {157if (highlight.end <= currentIndex || highlight.start >= endIndex) {158continue;159}160161if (highlight.start > currentIndex) {162children.push(node.textContent!.substring(currentIndex - startIndex, highlight.start - startIndex));163currentIndex = highlight.start;164}165166const highlightEnd = Math.min(highlight.end, endIndex);167const highlightedText = node.textContent!.substring(currentIndex - startIndex, highlightEnd - startIndex);168const highlightSpan = document.createElement('span');169highlightSpan.classList.add('highlight');170if (highlight.extraClasses) {171highlightSpan.classList.add(...highlight.extraClasses);172}173highlightSpan.textContent = highlightedText;174children.push(highlightSpan);175currentIndex = highlightEnd;176}177178if (currentIndex === startIndex) {179return Iterable.single(node); // no changes made180}181182if (currentIndex < endIndex) {183children.push(node.textContent!.substring(currentIndex - startIndex));184}185186// reuse the element if it's a link187if (isHTMLElement(node)) {188reset(node, ...children);189return Iterable.single(node);190}191192return children;193}194195/**196* Linkifies a location reference.197*/198linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior: DebugLinkHoverBehaviorTypeData) {199const link = this.createLink(text);200this.decorateLink(link, undefined, text, hoverBehavior, async (preserveFocus: boolean) => {201const location = await session.resolveLocationReference(locationReference);202await location.source.openInEditor(this.editorService, {203startLineNumber: location.line,204startColumn: location.column,205endLineNumber: location.endLine ?? location.line,206endColumn: location.endColumn ?? location.column,207}, preserveFocus);208});209210return link;211}212213/**214* Makes an {@link ILinkDetector} that links everything in the output to the215* reference if they don't have other explicit links.216*/217makeReferencedLinkDetector(locationReference: number, session: IDebugSession): ILinkDetector {218return {219linkify: (text, hoverBehavior, splitLines, workspaceFolder, includeFulltext, highlights) =>220this._linkify(text, hoverBehavior, splitLines, workspaceFolder, includeFulltext, highlights, { locationReference, session }),221linkifyLocation: this.linkifyLocation.bind(this),222};223}224225private createWebLink(fulltext: string | undefined, url: string, hoverBehavior: DebugLinkHoverBehaviorTypeData): Node {226const link = this.createLink(url);227228let uri = URI.parse(url);229// if the URI ends with something like `foo.js:12:3`, parse230// that into a fragment to reveal that location (#150702)231const lineCol = LINE_COLUMN_REGEX.exec(uri.path);232if (lineCol) {233uri = uri.with({234path: uri.path.slice(0, lineCol.index),235fragment: `L${lineCol[0].slice(1)}`236});237}238239this.decorateLink(link, uri, fulltext, hoverBehavior, async () => {240241if (uri.scheme === Schemas.file) {242// Just using fsPath here is unsafe: https://github.com/microsoft/vscode/issues/109076243const fsPath = uri.fsPath;244const path = await this.pathService.path;245const fileUrl = osPath.normalize(((path.sep === osPath.posix.sep) && platform.isWindows) ? fsPath.replace(/\\/g, osPath.posix.sep) : fsPath);246247const fileUri = URI.parse(fileUrl);248const exists = await this.fileService.exists(fileUri);249if (!exists) {250return;251}252253await this.editorService.openEditor({254resource: fileUri,255options: {256pinned: true,257selection: lineCol ? { startLineNumber: +lineCol[1], startColumn: lineCol[2] ? +lineCol[2] : 1 } : undefined,258},259});260return;261}262263this.openerService.open(url, { allowTunneling: (!!this.environmentService.remoteAuthority && this.configurationService.getValue('remote.forwardOnOpen')) });264});265266return link;267}268269private createPathLink(fulltext: string | undefined, text: string, path: string, lineNumber: number, columnNumber: number, workspaceFolder: IWorkspaceFolder | undefined, hoverBehavior: DebugLinkHoverBehaviorTypeData): Node {270if (path[0] === '/' && path[1] === '/') {271// Most likely a url part which did not match, for example ftp://path.272return document.createTextNode(text);273}274275// Only set selection if we have a valid line number (greater than 0)276const options = lineNumber > 0277? { selection: { startLineNumber: lineNumber, startColumn: columnNumber > 0 ? columnNumber : 1 } }278: {};279280if (path[0] === '.') {281if (!workspaceFolder) {282return document.createTextNode(text);283}284const uri = workspaceFolder.toResource(path);285const link = this.createLink(text);286this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));287return link;288}289290if (path[0] === '~') {291const userHome = this.pathService.resolvedUserHome;292if (userHome) {293path = osPath.join(userHome.fsPath, path.substring(1));294}295}296297const link = this.createLink(text);298link.tabIndex = 0;299const uri = URI.file(osPath.normalize(path));300this.fileService.stat(uri).then(stat => {301if (stat.isDirectory) {302return;303}304this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));305}).catch(() => {306// If the uri can not be resolved we should not spam the console with error, remain quite #86587307});308return link;309}310311private createLink(text: string): HTMLElement {312const link = document.createElement('a');313link.textContent = text;314return link;315}316317private decorateLink(link: HTMLElement, uri: URI | undefined, fulltext: string | undefined, hoverBehavior: DebugLinkHoverBehaviorTypeData, onClick: (preserveFocus: boolean) => void) {318if (hoverBehavior.store.isDisposed) {319return;320}321link.classList.add('link');322const followLink = uri && this.tunnelService.canTunnel(uri) ? localize('followForwardedLink', "follow link using forwarded port") : localize('followLink', "follow link");323const title = link.ariaLabel = fulltext324? (platform.isMacintosh ? localize('fileLinkWithPathMac', "Cmd + click to {0}\n{1}", followLink, fulltext) : localize('fileLinkWithPath', "Ctrl + click to {0}\n{1}", followLink, fulltext))325: (platform.isMacintosh ? localize('fileLinkMac', "Cmd + click to {0}", followLink) : localize('fileLink', "Ctrl + click to {0}", followLink));326327if (hoverBehavior.type === DebugLinkHoverBehavior.Rich) {328hoverBehavior.store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, title));329} else if (hoverBehavior.type !== DebugLinkHoverBehavior.None) {330link.title = title;331}332333hoverBehavior.store.add(addDisposableListener(link, 'mousemove', (event: MouseEvent) => {334link.classList.toggle('pointer', platform.isMacintosh ? event.metaKey : event.ctrlKey);335}));336337hoverBehavior.store.add(addDisposableListener(link, 'mouseleave', () => {338link.classList.remove('pointer');339}));340341hoverBehavior.store.add(addDisposableListener(link, 'click', (event: MouseEvent) => {342const selection = getWindow(link).getSelection();343if (!selection || selection.type === 'Range') {344return; // do not navigate when user is selecting345}346if (!(platform.isMacintosh ? event.metaKey : event.ctrlKey)) {347return;348}349350event.preventDefault();351event.stopImmediatePropagation();352onClick(false);353}));354355hoverBehavior.store.add(addDisposableListener(link, 'keydown', (e: KeyboardEvent) => {356const event = new StandardKeyboardEvent(e);357if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {358event.preventDefault();359event.stopPropagation();360onClick(event.keyCode === KeyCode.Space);361}362}));363}364365private detectLinks(text: string): LinkPart[] {366if (text.length > MAX_LENGTH) {367return [{ kind: 'text', value: text, captures: [], index: 0 }];368}369370const regexes: RegExp[] = [WEB_LINK_REGEX, PATH_LINK_REGEX];371const kinds: LinkKind[] = ['web', 'path'];372const result: LinkPart[] = [];373374const splitOne = (text: string, regexIndex: number, baseIndex: number) => {375if (regexIndex >= regexes.length) {376result.push({ value: text, kind: 'text', captures: [], index: baseIndex });377return;378}379const regex = regexes[regexIndex];380let currentIndex = 0;381let match;382regex.lastIndex = 0;383while ((match = regex.exec(text)) !== null) {384const stringBeforeMatch = text.substring(currentIndex, match.index);385if (stringBeforeMatch) {386splitOne(stringBeforeMatch, regexIndex + 1, baseIndex + currentIndex);387}388const value = match[0];389result.push({390value: value,391kind: kinds[regexIndex],392captures: match.slice(1),393index: baseIndex + match.index394});395currentIndex = match.index + value.length;396}397const stringAfterMatches = text.substring(currentIndex);398if (stringAfterMatches) {399splitOne(stringAfterMatches, regexIndex + 1, baseIndex + currentIndex);400}401};402403splitOne(text, 0, 0);404return result;405}406}407408409