Path: blob/main/src/vs/workbench/contrib/debug/browser/linkDetector.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 { 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\.-]*)+)/;32const WIN_RELATIVE_PATH = /(?:(?:\~|\.+)(?:(?:\\|\/)[\w\.-]*)+)/;33const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);34const POSIX_PATH = /((?:\~|\.+)?(?:\/[\w\.-]*)+)/;35const LINE_COLUMN = /(?:\:([\d]+))?(?:\:([\d]+))?/;36const PATH_LINK_REGEX = new RegExp(`${platform.isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');37const LINE_COLUMN_REGEX = /:([\d]+)(?::([\d]+))?$/;3839const MAX_LENGTH = 2000;4041type LinkKind = 'web' | 'path' | 'text';42type LinkPart = {43kind: LinkKind;44value: string;45captures: string[];46index: number;47};4849export const enum DebugLinkHoverBehavior {50/** A nice workbench hover */51Rich,52/**53* Basic browser hover54* @deprecated Consumers should adopt `rich` by propagating disposables appropriately55*/56Basic,57/** No hover */58None59}6061/** Store implies HoverBehavior=rich */62export type DebugLinkHoverBehaviorTypeData = { type: DebugLinkHoverBehavior.None | DebugLinkHoverBehavior.Basic }63| { type: DebugLinkHoverBehavior.Rich; store: DisposableStore };6465export interface ILinkDetector {66linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement;67linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior?: DebugLinkHoverBehaviorTypeData): HTMLElement;68}6970export class LinkDetector implements ILinkDetector {71constructor(72@IEditorService private readonly editorService: IEditorService,73@IFileService private readonly fileService: IFileService,74@IOpenerService private readonly openerService: IOpenerService,75@IPathService private readonly pathService: IPathService,76@ITunnelService private readonly tunnelService: ITunnelService,77@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,78@IConfigurationService private readonly configurationService: IConfigurationService,79@IHoverService private readonly hoverService: IHoverService,80) {81// noop82}8384/**85* Matches and handles web urls, absolute and relative file links in the string provided.86* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/>.87* 'onclick' event is attached to all anchored links that opens them in the editor.88* When splitLines is true, each line of the text, even if it contains no links, is wrapped in a <span>89* and added as a child of the returned <span>.90* If a `hoverBehavior` is passed, hovers may be added using the workbench hover service.91* This should be preferred for new code where hovers are desirable.92*/93linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement {94return this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights);95}9697private _linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[], defaultRef?: { locationReference: number; session: IDebugSession }): HTMLElement {98if (splitLines) {99const lines = text.split('\n');100for (let i = 0; i < lines.length - 1; i++) {101lines[i] = lines[i] + '\n';102}103if (!lines[lines.length - 1]) {104// Remove the last element ('') that split added.105lines.pop();106}107const elements = lines.map(line => this._linkify(line, false, workspaceFolder, includeFulltext, hoverBehavior, highlights, defaultRef));108if (elements.length === 1) {109// Do not wrap single line with extra span.110return elements[0];111}112const container = document.createElement('span');113elements.forEach(e => container.appendChild(e));114return container;115}116117const container = document.createElement('span');118for (const part of this.detectLinks(text)) {119try {120let node: Node;121switch (part.kind) {122case 'text':123node = defaultRef ? this.linkifyLocation(part.value, defaultRef.locationReference, defaultRef.session, hoverBehavior) : document.createTextNode(part.value);124break;125case 'web':126node = this.createWebLink(includeFulltext ? text : undefined, part.value, hoverBehavior);127break;128case 'path': {129const path = part.captures[0];130const lineNumber = part.captures[1] ? Number(part.captures[1]) : 0;131const columnNumber = part.captures[2] ? Number(part.captures[2]) : 0;132node = this.createPathLink(includeFulltext ? text : undefined, part.value, path, lineNumber, columnNumber, workspaceFolder, hoverBehavior);133break;134}135default:136node = document.createTextNode(part.value);137}138139container.append(...this.applyHighlights(node, part.index, part.value.length, highlights));140} catch (e) {141container.appendChild(document.createTextNode(part.value));142}143}144return container;145}146147private applyHighlights(node: Node, startIndex: number, length: number, highlights: IHighlight[] | undefined): Iterable<Node | string> {148const children: (Node | string)[] = [];149let currentIndex = startIndex;150const endIndex = startIndex + length;151152for (const highlight of highlights || []) {153if (highlight.end <= currentIndex || highlight.start >= endIndex) {154continue;155}156157if (highlight.start > currentIndex) {158children.push(node.textContent!.substring(currentIndex - startIndex, highlight.start - startIndex));159currentIndex = highlight.start;160}161162const highlightEnd = Math.min(highlight.end, endIndex);163const highlightedText = node.textContent!.substring(currentIndex - startIndex, highlightEnd - startIndex);164const highlightSpan = document.createElement('span');165highlightSpan.classList.add('highlight');166if (highlight.extraClasses) {167highlightSpan.classList.add(...highlight.extraClasses);168}169highlightSpan.textContent = highlightedText;170children.push(highlightSpan);171currentIndex = highlightEnd;172}173174if (currentIndex === startIndex) {175return Iterable.single(node); // no changes made176}177178if (currentIndex < endIndex) {179children.push(node.textContent!.substring(currentIndex - startIndex));180}181182// reuse the element if it's a link183if (isHTMLElement(node)) {184reset(node, ...children);185return Iterable.single(node);186}187188return children;189}190191/**192* Linkifies a location reference.193*/194linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior?: DebugLinkHoverBehaviorTypeData) {195const link = this.createLink(text);196this.decorateLink(link, undefined, text, hoverBehavior, async (preserveFocus: boolean) => {197const location = await session.resolveLocationReference(locationReference);198await location.source.openInEditor(this.editorService, {199startLineNumber: location.line,200startColumn: location.column,201endLineNumber: location.endLine ?? location.line,202endColumn: location.endColumn ?? location.column,203}, preserveFocus);204});205206return link;207}208209/**210* Makes an {@link ILinkDetector} that links everything in the output to the211* reference if they don't have other explicit links.212*/213makeReferencedLinkDetector(locationReference: number, session: IDebugSession): ILinkDetector {214return {215linkify: (text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights) =>216this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights, { locationReference, session }),217linkifyLocation: this.linkifyLocation.bind(this),218};219}220221private createWebLink(fulltext: string | undefined, url: string, hoverBehavior?: DebugLinkHoverBehaviorTypeData): Node {222const link = this.createLink(url);223224let uri = URI.parse(url);225// if the URI ends with something like `foo.js:12:3`, parse226// that into a fragment to reveal that location (#150702)227const lineCol = LINE_COLUMN_REGEX.exec(uri.path);228if (lineCol) {229uri = uri.with({230path: uri.path.slice(0, lineCol.index),231fragment: `L${lineCol[0].slice(1)}`232});233}234235this.decorateLink(link, uri, fulltext, hoverBehavior, async () => {236237if (uri.scheme === Schemas.file) {238// Just using fsPath here is unsafe: https://github.com/microsoft/vscode/issues/109076239const fsPath = uri.fsPath;240const path = await this.pathService.path;241const fileUrl = osPath.normalize(((path.sep === osPath.posix.sep) && platform.isWindows) ? fsPath.replace(/\\/g, osPath.posix.sep) : fsPath);242243const fileUri = URI.parse(fileUrl);244const exists = await this.fileService.exists(fileUri);245if (!exists) {246return;247}248249await this.editorService.openEditor({250resource: fileUri,251options: {252pinned: true,253selection: lineCol ? { startLineNumber: +lineCol[1], startColumn: +lineCol[2] } : undefined,254},255});256return;257}258259this.openerService.open(url, { allowTunneling: (!!this.environmentService.remoteAuthority && this.configurationService.getValue('remote.forwardOnOpen')) });260});261262return link;263}264265private createPathLink(fulltext: string | undefined, text: string, path: string, lineNumber: number, columnNumber: number, workspaceFolder: IWorkspaceFolder | undefined, hoverBehavior?: DebugLinkHoverBehaviorTypeData): Node {266if (path[0] === '/' && path[1] === '/') {267// Most likely a url part which did not match, for example ftp://path.268return document.createTextNode(text);269}270271const options = { selection: { startLineNumber: lineNumber, startColumn: columnNumber } };272if (path[0] === '.') {273if (!workspaceFolder) {274return document.createTextNode(text);275}276const uri = workspaceFolder.toResource(path);277const link = this.createLink(text);278this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));279return link;280}281282if (path[0] === '~') {283const userHome = this.pathService.resolvedUserHome;284if (userHome) {285path = osPath.join(userHome.fsPath, path.substring(1));286}287}288289const link = this.createLink(text);290link.tabIndex = 0;291const uri = URI.file(osPath.normalize(path));292this.fileService.stat(uri).then(stat => {293if (stat.isDirectory) {294return;295}296this.decorateLink(link, uri, fulltext, hoverBehavior, (preserveFocus: boolean) => this.editorService.openEditor({ resource: uri, options: { ...options, preserveFocus } }));297}).catch(() => {298// If the uri can not be resolved we should not spam the console with error, remain quite #86587299});300return link;301}302303private createLink(text: string): HTMLElement {304const link = document.createElement('a');305link.textContent = text;306return link;307}308309private decorateLink(link: HTMLElement, uri: URI | undefined, fulltext: string | undefined, hoverBehavior: DebugLinkHoverBehaviorTypeData | undefined, onClick: (preserveFocus: boolean) => void) {310link.classList.add('link');311const followLink = uri && this.tunnelService.canTunnel(uri) ? localize('followForwardedLink', "follow link using forwarded port") : localize('followLink', "follow link");312const title = link.ariaLabel = fulltext313? (platform.isMacintosh ? localize('fileLinkWithPathMac', "Cmd + click to {0}\n{1}", followLink, fulltext) : localize('fileLinkWithPath', "Ctrl + click to {0}\n{1}", followLink, fulltext))314: (platform.isMacintosh ? localize('fileLinkMac', "Cmd + click to {0}", followLink) : localize('fileLink', "Ctrl + click to {0}", followLink));315316if (hoverBehavior?.type === DebugLinkHoverBehavior.Rich) {317hoverBehavior.store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, title));318} else if (hoverBehavior?.type !== DebugLinkHoverBehavior.None) {319link.title = title;320}321322link.onmousemove = (event) => { link.classList.toggle('pointer', platform.isMacintosh ? event.metaKey : event.ctrlKey); };323link.onmouseleave = () => link.classList.remove('pointer');324link.onclick = (event) => {325const selection = getWindow(link).getSelection();326if (!selection || selection.type === 'Range') {327return; // do not navigate when user is selecting328}329if (!(platform.isMacintosh ? event.metaKey : event.ctrlKey)) {330return;331}332333event.preventDefault();334event.stopImmediatePropagation();335onClick(false);336};337link.onkeydown = e => {338const event = new StandardKeyboardEvent(e);339if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {340event.preventDefault();341event.stopPropagation();342onClick(event.keyCode === KeyCode.Space);343}344};345}346347private detectLinks(text: string): LinkPart[] {348if (text.length > MAX_LENGTH) {349return [{ kind: 'text', value: text, captures: [], index: 0 }];350}351352const regexes: RegExp[] = [WEB_LINK_REGEX, PATH_LINK_REGEX];353const kinds: LinkKind[] = ['web', 'path'];354const result: LinkPart[] = [];355356const splitOne = (text: string, regexIndex: number, baseIndex: number) => {357if (regexIndex >= regexes.length) {358result.push({ value: text, kind: 'text', captures: [], index: baseIndex });359return;360}361const regex = regexes[regexIndex];362let currentIndex = 0;363let match;364regex.lastIndex = 0;365while ((match = regex.exec(text)) !== null) {366const stringBeforeMatch = text.substring(currentIndex, match.index);367if (stringBeforeMatch) {368splitOne(stringBeforeMatch, regexIndex + 1, baseIndex + currentIndex);369}370const value = match[0];371result.push({372value: value,373kind: kinds[regexIndex],374captures: match.slice(1),375index: baseIndex + match.index376});377currentIndex = match.index + value.length;378}379const stringAfterMatches = text.substring(currentIndex);380if (stringAfterMatches) {381splitOne(stringAfterMatches, regexIndex + 1, baseIndex + currentIndex);382}383};384385splitOne(text, 0, 0);386return result;387}388}389390391