Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support validated regex-based link detection and word-based as a fallback that uses quick access #95072

Merged
merged 14 commits into from
Apr 13, 2020
117 changes: 117 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IViewportRange, IBufferRange, IBufferLine, IBuffer, IBufferCellPosition } from 'xterm';
import { IRange } from 'vs/editor/common/core/range';

export const TOOLTIP_HOVER_THRESHOLD = 500;

export function convertLinkRangeToBuffer(lines: IBufferLine[], bufferWidth: number, range: IRange, startLine: number) {
const bufferRange: IBufferRange = {
start: {
x: range.startColumn,
y: range.startLineNumber + startLine
},
end: {
x: range.endColumn - 1,
y: range.endLineNumber + startLine
}
};

// Shift start range right for each wide character before the link
let startOffset = 0;
const startWrappedLineCount = Math.ceil(range.startColumn / bufferWidth);
for (let y = 0; y < startWrappedLineCount; y++) {
const lineLength = Math.min(bufferWidth, range.startColumn - y * bufferWidth);
let lineOffset = 0;
const line = lines[y];
for (let x = 0; x < Math.min(bufferWidth, lineLength + lineOffset); x++) {
const cell = line.getCell(x)!;
const width = cell.getWidth();
if (width === 2) {
lineOffset++;
}
const char = cell.getChars();
if (char.length > 1) {
lineOffset -= char.length - 1;
}
}
startOffset += lineOffset;
}

// Shift end range right for each wide character inside the link
let endOffset = 0;
const endWrappedLineCount = Math.ceil(range.endColumn / bufferWidth);
for (let y = Math.max(0, startWrappedLineCount - 1); y < endWrappedLineCount; y++) {
const start = (y === startWrappedLineCount - 1 ? (range.startColumn + startOffset) % bufferWidth : 0);
const lineLength = Math.min(bufferWidth, range.endColumn + startOffset - y * bufferWidth);
const startLineOffset = (y === startWrappedLineCount - 1 ? startOffset : 0);
let lineOffset = 0;
const line = lines[y];
for (let x = start; x < Math.min(bufferWidth, lineLength + lineOffset + startLineOffset); x++) {
const cell = line.getCell(x)!;
const width = cell.getWidth();
// Offset for 0 cells following wide characters
if (width === 2) {
lineOffset++;
}
// Offset for early wrapping when the last cell in row is a wide character
if (x === bufferWidth - 1 && cell.getChars() === '') {
lineOffset++;
}
}
endOffset += lineOffset;
}

// Apply the width character offsets to the result
bufferRange.start.x += startOffset;
bufferRange.end.x += startOffset + endOffset;

// Convert back to wrapped lines
while (bufferRange.start.x > bufferWidth) {
bufferRange.start.x -= bufferWidth;
bufferRange.start.y++;
}
while (bufferRange.end.x > bufferWidth) {
bufferRange.end.x -= bufferWidth;
bufferRange.end.y++;
}

return bufferRange;
}

export function convertBufferRangeToViewport(bufferRange: IBufferRange, viewportY: number): IViewportRange {
return {
start: {
x: bufferRange.start.x - 1,
y: bufferRange.start.y - viewportY - 1
},
end: {
x: bufferRange.end.x - 1,
y: bufferRange.end.y - viewportY - 1
}
};
}

export function getXtermLineContent(buffer: IBuffer, lineStart: number, lineEnd: number): string {
let line = '';
for (let i = lineStart; i <= lineEnd; i++) {
line += buffer.getLine(i)?.translateToString(true);
}
return line;
}

export function positionIsInRange(position: IBufferCellPosition, range: IBufferRange): boolean {
if (position.y < range.start.y || position.y > range.end.y) {
return false;
}
if (position.y === range.start.y && position.x < range.start.x) {
return false;
}
if (position.y === range.end.y && position.x > range.end.x) {
return false;
}
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import * as nls from 'vs/nls';
import { URI } from 'vs/base/common/uri';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { DisposableStore, IDisposable, dispose } from 'vs/base/common/lifecycle';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { TerminalWidgetManager, WidgetVerticalAlignment } from 'vs/workbench/contrib/terminal/browser/terminalWidgetManager';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
Expand All @@ -17,11 +17,15 @@ import { Terminal, ILinkMatcherOptions, IViewportRange, ITerminalAddon } from 'x
import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts';
import { posix, win32 } from 'vs/base/common/path';
import { ITerminalInstanceService, ITerminalBeforeHandleLinkEvent, LINK_INTERCEPT_THRESHOLD } from 'vs/workbench/contrib/terminal/browser/terminal';
import { OperatingSystem, isMacintosh } from 'vs/base/common/platform';
import { OperatingSystem, isMacintosh, OS } from 'vs/base/common/platform';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { Emitter, Event } from 'vs/base/common/event';
import { ILogService } from 'vs/platform/log/common/log';
import { TerminalWebLinkProvider } from 'vs/workbench/contrib/terminal/browser/terminalWebLinkProvider';
import { TerminalWebLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalWebLinkProvider';
import { TerminalValidatedLocalLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider';
import { TerminalWordLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';

const pathPrefix = '(\\.\\.?|\\~)';
const pathSeparatorClause = '\\/';
Expand Down Expand Up @@ -69,7 +73,10 @@ interface IPath {
normalize(path: string): string;
}

export class TerminalLinkHandler extends DisposableStore {
/**
* An object responsible for managing registration of link matchers and link providers.
*/
export class TerminalLinkManager extends DisposableStore {
private _widgetManager: TerminalWidgetManager | undefined;
private _processCwd: string | undefined;
private _gitDiffPreImagePattern: RegExp;
Expand All @@ -78,11 +85,11 @@ export class TerminalLinkHandler extends DisposableStore {
private readonly _leaveCallback: () => void;
private _linkMatchers: number[] = [];
private _webLinksAddon: ITerminalAddon | undefined;
private _linkProvider: IDisposable | undefined;
private _linkProviders: IDisposable[] = [];
private _hasBeforeHandleLinkListeners = false;

protected static _LINK_INTERCEPT_THRESHOLD = LINK_INTERCEPT_THRESHOLD;
public static readonly LINK_INTERCEPT_THRESHOLD = TerminalLinkHandler._LINK_INTERCEPT_THRESHOLD;
public static readonly LINK_INTERCEPT_THRESHOLD = TerminalLinkManager._LINK_INTERCEPT_THRESHOLD;

private readonly _onBeforeHandleLink = this.add(new Emitter<ITerminalBeforeHandleLinkEvent>({
onFirstListenerAdd: () => this._hasBeforeHandleLinkListeners = true,
Expand All @@ -97,14 +104,16 @@ export class TerminalLinkHandler extends DisposableStore {

constructor(
private _xterm: Terminal,
private readonly _processManager: ITerminalProcessManager | undefined,
private readonly _processManager: ITerminalProcessManager,
private readonly _configHelper: ITerminalConfigHelper,
@IOpenerService private readonly _openerService: IOpenerService,
@IEditorService private readonly _editorService: IEditorService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService,
@IFileService private readonly _fileService: IFileService,
@ILogService private readonly _logService: ILogService
@ILogService private readonly _logService: ILogService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IQuickInputService private readonly _quickInputService: IQuickInputService
) {
super();

Expand Down Expand Up @@ -169,7 +178,8 @@ export class TerminalLinkHandler extends DisposableStore {
this._deregisterLinkMatchers();
this.registerLinkProvider();
} else {
this._linkProvider?.dispose();
dispose(this._linkProviders);
this._linkProviders.length = 0;
this._registerLinkMatchers();
}
}
Expand Down Expand Up @@ -277,11 +287,31 @@ export class TerminalLinkHandler extends DisposableStore {

public registerLinkProvider(): void {
// Web links
const tooltipCallback = (event: MouseEvent, link: string, location: IViewportRange) => {
const tooltipWebCallback = (event: MouseEvent, link: string, location: IViewportRange) => {
this._tooltipCallback(event, link, location, this._handleHypertextLink.bind(this, link));
};
const wrappedActivateCallback = this._wrapLinkHandler(this._handleHypertextLink.bind(this));
this._linkProvider = this._xterm.registerLinkProvider(new TerminalWebLinkProvider(this._xterm, wrappedActivateCallback, tooltipCallback, this._leaveCallback));
this._linkProviders.push(this._xterm.registerLinkProvider(
new TerminalWebLinkProvider(this._xterm, wrappedActivateCallback, tooltipWebCallback, this._leaveCallback)
));

// Validated local links
const tooltipValidatedLocalCallback = (event: MouseEvent, link: string, location: IViewportRange) => {
this._tooltipCallback(event, link, location, this._handleLocalLink.bind(this, link));
};
const wrappedLinkActivateCallback = this._wrapLinkHandler(this._handleLocalLink.bind(this));
this._linkProviders.push(this._xterm.registerLinkProvider(
new TerminalValidatedLocalLinkProvider(this._xterm, this._processManager.os || OS, wrappedLinkActivateCallback, tooltipValidatedLocalCallback, this._leaveCallback, this._validateLocalLink.bind(this))
));

// Word links
const tooltipWordCallback = (event: MouseEvent, link: string, location: IViewportRange) => {
this._tooltipCallback(event, link, location, link => this._quickInputService.quickAccess.show(link));
};
const wrappedWordActivateCallback = this._wrapLinkHandler(link => this._quickInputService.quickAccess.show(link));
this._linkProviders.push(this._xterm.registerLinkProvider(
this._instantiationService.createInstance(TerminalWordLinkProvider, this._xterm, wrappedWordActivateCallback, tooltipWordCallback, this._leaveCallback)
));
}

protected _wrapLinkHandler(handler: (link: string) => void): XtermLinkMatcherHandler {
Expand All @@ -298,9 +328,9 @@ export class TerminalLinkHandler extends DisposableStore {
const wasHandled = await new Promise<boolean>(r => {
const timeoutId = setTimeout(() => {
canceled = true;
this._logService.error(`An extension intecepted a terminal link but it timed out after ${TerminalLinkHandler.LINK_INTERCEPT_THRESHOLD / 1000} seconds`);
this._logService.error(`An extension intecepted a terminal link but it timed out after ${TerminalLinkManager.LINK_INTERCEPT_THRESHOLD / 1000} seconds`);
r(false);
}, TerminalLinkHandler.LINK_INTERCEPT_THRESHOLD);
}, TerminalLinkManager.LINK_INTERCEPT_THRESHOLD);
let canceled = false;
const resolve = (handled: boolean) => {
if (!canceled) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Terminal, ILinkProvider, IViewportRange, IBufferCellPosition, ILink, IBufferLine } from 'xterm';
import { getXtermLineContent, convertLinkRangeToBuffer, convertBufferRangeToViewport, positionIsInRange, TOOLTIP_HOVER_THRESHOLD } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { OperatingSystem } from 'vs/base/common/platform';

const pathPrefix = '(\\.\\.?|\\~)';
const pathSeparatorClause = '\\/';
// '":; are allowed in paths but they are often separators so ignore them
// Also disallow \\ to prevent a catastropic backtracking case #24798
const excludedPathCharactersClause = '[^\\0\\s!$`&*()\\[\\]+\'":;\\\\]';
/** A regex that matches paths in the form /foo, ~/foo, ./foo, ../foo, foo/bar */
const unixLocalLinkClause = '((' + pathPrefix + '|(' + excludedPathCharactersClause + ')+)?(' + pathSeparatorClause + '(' + excludedPathCharactersClause + ')+)+)';

const winDrivePrefix = '[a-zA-Z]:';
const winPathPrefix = '(' + winDrivePrefix + '|\\.\\.?|\\~)';
const winPathSeparatorClause = '(\\\\|\\/)';
const winExcludedPathCharactersClause = '[^\\0<>\\?\\|\\/\\s!$`&*()\\[\\]+\'":;]';
/** A regex that matches paths in the form c:\foo, ~\foo, .\foo, ..\foo, foo\bar */
const winLocalLinkClause = '((' + winPathPrefix + '|(' + winExcludedPathCharactersClause + ')+)?(' + winPathSeparatorClause + '(' + winExcludedPathCharactersClause + ')+)+)';

/** As xterm reads from DOM, space in that case is nonbreaking char ASCII code - 160,
replacing space with nonBreakningSpace or space ASCII code - 32. */
const lineAndColumnClause = [
'((\\S*)", line ((\\d+)( column (\\d+))?))', // "(file path)", line 45 [see #40468]
'((\\S*)",((\\d+)(:(\\d+))?))', // "(file path)",45 [see #78205]
'((\\S*) on line ((\\d+)(, column (\\d+))?))', // (file path) on line 8, column 13
'((\\S*):line ((\\d+)(, column (\\d+))?))', // (file path):line 8, column 13
'(([^\\s\\(\\)]*)(\\s?[\\(\\[](\\d+)(,\\s?(\\d+))?)[\\)\\]])', // (file path)(45), (file path) (45), (file path)(45,18), (file path) (45,18), (file path)(45, 18), (file path) (45, 18), also with []
'(([^:\\s\\(\\)<>\'\"\\[\\]]*)(:(\\d+))?(:(\\d+))?)' // (file path):336, (file path):336:9
].join('|').replace(/ /g, `[${'\u00A0'} ]`);

export class TerminalValidatedLocalLinkProvider implements ILinkProvider {
constructor(
private readonly _xterm: Terminal,
private readonly _processOperatingSystem: OperatingSystem,
private readonly _activateCallback: (event: MouseEvent, uri: string) => void,
private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange) => boolean | void,
private readonly _leaveCallback: () => void,
private readonly _validationCallback: (uri: string, callback: (isValid: boolean) => void) => void
) {
}

public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void {
let startLine = position.y - 1;
let endLine = startLine;

const lines: IBufferLine[] = [
this._xterm.buffer.active.getLine(startLine)!
];

while (this._xterm.buffer.active.getLine(startLine)?.isWrapped) {
lines.unshift(this._xterm.buffer.active.getLine(startLine - 1)!);
startLine--;
}

while (this._xterm.buffer.active.getLine(endLine + 1)?.isWrapped) {
lines.push(this._xterm.buffer.active.getLine(endLine + 1)!);
endLine++;
}

const text = getXtermLineContent(this._xterm.buffer.active, startLine, endLine);

// clone regex to do a global search on text
const rex = new RegExp(this._localLinkRegex, 'g');
let match;
let stringIndex = -1;
while ((match = rex.exec(text)) !== null) {
// const uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
const uri = match[0];
if (!uri) {
// something matched but does not comply with the given matchIndex
// since this is most likely a bug the regex itself we simply do nothing here
// this._logService.debug('match found without corresponding matchIndex', match, matcher);
break;
}

// Get index, match.index is for the outer match which includes negated chars
// therefore we cannot use match.index directly, instead we search the position
// of the match group in text again
// also correct regex and string search offsets for the next loop run
stringIndex = text.indexOf(uri, stringIndex + 1);
rex.lastIndex = stringIndex + uri.length;
if (stringIndex < 0) {
// invalid stringIndex (should not have happened)
break;
}

// Convert the link text's string index into a wrapped buffer range
const bufferRange = convertLinkRangeToBuffer(lines, this._xterm.cols, {
startColumn: stringIndex + 1,
startLineNumber: 1,
endColumn: stringIndex + uri.length + 1,
endLineNumber: 1
}, startLine);

if (positionIsInRange(position, bufferRange)) {
this._validationCallback(uri, isValid => {
if (isValid) {
let timeout: number | undefined;
callback({
text: uri,
range: bufferRange,
activate: (event: MouseEvent, text: string) => this._activateCallback(event, text),
hover: (event: MouseEvent, text: string) => {
timeout = window.setTimeout(() => {
this._tooltipCallback(event, text, convertBufferRangeToViewport(bufferRange, this._xterm.buffer.active.viewportY));
}, TOOLTIP_HOVER_THRESHOLD);
},
leave: () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
this._leaveCallback();
}
});
} else {
callback(undefined);
}
});
} else {
callback(undefined);
}
return;
}

callback(undefined);
}

protected get _localLinkRegex(): RegExp {
const baseLocalLinkClause = this._processOperatingSystem === OperatingSystem.Windows ? winLocalLinkClause : unixLocalLinkClause;
// Append line and column number regex
return new RegExp(`${baseLocalLinkClause}(${lineAndColumnClause})`);
}
}
Loading