diff --git a/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts new file mode 100644 index 0000000000000..ebc5e97af7eab --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts @@ -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; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkManager.ts similarity index 87% rename from src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts rename to src/vs/workbench/contrib/terminal/browser/links/terminalLinkManager.ts index 095d513717308..8d33aebf5ed87 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkManager.ts @@ -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'; @@ -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 = '\\/'; @@ -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; @@ -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({ onFirstListenerAdd: () => this._hasBeforeHandleLinkListeners = true, @@ -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(); @@ -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(); } } @@ -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 { @@ -298,9 +328,9 @@ export class TerminalLinkHandler extends DisposableStore { const wasHandled = await new Promise(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) { diff --git a/src/vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider.ts new file mode 100644 index 0000000000000..21f83756038fd --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider.ts @@ -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})`); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/links/terminalWebLinkProvider.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalWebLinkProvider.ts new file mode 100644 index 0000000000000..8d0e9909832e3 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalWebLinkProvider.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Terminal, IViewportRange, ILinkProvider, IBufferCellPosition, ILink, IBufferLine } from 'xterm'; +import { ILinkComputerTarget, LinkComputer } from 'vs/editor/common/modes/linkComputer'; +import { getXtermLineContent, convertLinkRangeToBuffer, convertBufferRangeToViewport, positionIsInRange, TOOLTIP_HOVER_THRESHOLD } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers'; + +export class TerminalWebLinkProvider implements ILinkProvider { + private _linkComputerTarget: ILinkComputerTarget | undefined; + + constructor( + private readonly _xterm: Terminal, + private readonly _activateCallback: (event: MouseEvent, uri: string) => void, + private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange) => boolean | void, + private readonly _leaveCallback: () => 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++; + } + + this._linkComputerTarget = new TerminalLinkAdapter(this._xterm, startLine, endLine); + const links = LinkComputer.computeLinks(this._linkComputerTarget); + + let found = false; + links.forEach(link => { + const range = convertLinkRangeToBuffer(lines, this._xterm.cols, link.range, startLine); + + // Check if the link if within the mouse position + if (positionIsInRange(position, range)) { + found = true; + + let timeout: number | undefined; + callback({ + text: link.url?.toString() || '', + range, + activate: (event: MouseEvent, text: string) => this._activateCallback(event, text), + hover: (event: MouseEvent, text: string) => { + timeout = window.setTimeout(() => { + this._tooltipCallback(event, text, convertBufferRangeToViewport(range, this._xterm.buffer.active.viewportY)); + }, TOOLTIP_HOVER_THRESHOLD); + }, + leave: () => { + if (timeout !== undefined) { + window.clearTimeout(timeout); + } + this._leaveCallback(); + } + }); + } + }); + + if (!found) { + callback(undefined); + } + } +} + +class TerminalLinkAdapter implements ILinkComputerTarget { + constructor( + private _xterm: Terminal, + private _lineStart: number, + private _lineEnd: number + ) { } + + getLineCount(): number { + return 1; + } + + getLineContent(): string { + return getXtermLineContent(this._xterm.buffer.active, this._lineStart, this._lineEnd); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider.ts new file mode 100644 index 0000000000000..01c4251d4d5c8 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'xterm'; +import { convertBufferRangeToViewport, TOOLTIP_HOVER_THRESHOLD } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ITerminalConfiguration, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal'; + +export class TerminalWordLinkProvider implements ILinkProvider { + constructor( + private readonly _xterm: Terminal, + private readonly _activateCallback: (event: MouseEvent, uri: string) => void, + private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange) => boolean | void, + private readonly _leaveCallback: () => void, + @IConfigurationService private readonly _configurationService: IConfigurationService + ) { + } + + public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { + const start: IBufferCellPosition = { x: position.x, y: position.y }; + const end: IBufferCellPosition = { x: position.x, y: position.y }; + + // TODO: Support wrapping + + // Expand to the left until a word separator is hit + const line = this._xterm.buffer.active.getLine(position.y - 1)!; + let text = ''; + start.x++; // The hovered cell is considered first + for (let x = position.x; x > 0; x--) { + const char = line.getCell(x - 1)?.getChars(); + if (!char) { + break; + } + const config = this._configurationService.getValue(TERMINAL_CONFIG_SECTION); + if (config.wordSeparators.indexOf(char) >= 0) { + break; + } + start.x--; + text = char + text; + } + + // No links were found (the hovered cell is whitespace) + if (text.length === 0) { + callback(undefined); + return; + } + + // Expand to the right until a word separator is hit + // end.x++; // The hovered cell is considered first + for (let x = position.x + 1; x <= line.length; x++) { + const char = line.getCell(x - 1)?.getChars(); + if (!char) { + break; + } + const config = this._configurationService.getValue(TERMINAL_CONFIG_SECTION); + if (config.wordSeparators.indexOf(char) >= 0) { + break; + } + end.x++; + text += char; + } + + const range = { start, end }; + let timeout: number | undefined; + callback({ + text, + range, + activate: (event: MouseEvent, text: string) => this._activateCallback(event, text), + hover: (event: MouseEvent, text: string) => { + timeout = window.setTimeout(() => { + this._tooltipCallback(event, text, convertBufferRangeToViewport(range, this._xterm.buffer.active.viewportY)); + }, TOOLTIP_HOVER_THRESHOLD); + }, + leave: () => { + if (timeout !== undefined) { + window.clearTimeout(timeout); + } + this._leaveCallback(); + } + }); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 5e047de7b2c8f..848cfbba16cff 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -28,7 +28,7 @@ import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/ter import { IShellLaunchConfig, ITerminalDimensions, ITerminalProcessManager, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, NEVER_MEASURE_RENDER_TIME_STORAGE_KEY, ProcessState, TERMINAL_VIEW_ID, IWindowsShellHelper, SHELL_PATH_INVALID_EXIT_CODE, SHELL_PATH_DIRECTORY_EXIT_CODE, SHELL_CWD_INVALID_EXIT_CODE, KEYBINDING_CONTEXT_TERMINAL_A11Y_TREE_FOCUS, INavigationMode, TitleEventSource, LEGACY_CONSOLE_MODE_EXIT_CODE, DEFAULT_COMMANDS_TO_SKIP_SHELL } from 'vs/workbench/contrib/terminal/common/terminal'; import { ansiColorIdentifiers, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; -import { TerminalLinkHandler } from 'vs/workbench/contrib/terminal/browser/terminalLinkHandler'; +import { TerminalLinkManager } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { ITerminalInstanceService, ITerminalInstance, TerminalShellType, ITerminalBeforeHandleLinkEvent } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager'; @@ -96,7 +96,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _messageTitleDisposable: IDisposable | undefined; private _widgetManager: TerminalWidgetManager | undefined; - private _linkHandler: TerminalLinkHandler | undefined; + private _linkManager: TerminalLinkManager | undefined; private _commandTrackerAddon: CommandTrackerAddon | undefined; private _navigationModeAddon: INavigationMode & ITerminalAddon | undefined; @@ -388,8 +388,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._processManager.onProcessData(data => this._onProcessData(data)); this._xterm.onData(data => this._processManager.write(data)); this.processReady.then(async () => { - if (this._linkHandler) { - this._linkHandler.processCwd = await this._processManager.getInitialCwd(); + if (this._linkManager) { + this._linkManager.processCwd = await this._processManager.getInitialCwd(); } }); // Init winpty compat and link handler after process creation as they rely on the @@ -405,8 +405,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { return false; }); } - this._linkHandler = this._instantiationService.createInstance(TerminalLinkHandler, xterm, this._processManager, this._configHelper); - this._linkHandler.onBeforeHandleLink(e => { + this._linkManager = this._instantiationService.createInstance(TerminalLinkManager, xterm, this._processManager!, this._configHelper); + this._linkManager.onBeforeHandleLink(e => { e.terminal = this; this._onBeforeHandleLink.fire(e); }); @@ -584,7 +584,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { const widgetManager = new TerminalWidgetManager(this._wrapperElement); this._widgetManager = widgetManager; - this._processManager.onProcessReady(() => this._linkHandler?.setWidgetManager(widgetManager)); + this._processManager.onProcessReady(() => this._linkManager?.setWidgetManager(widgetManager)); const computedStyle = window.getComputedStyle(this._container); const width = parseInt(computedStyle.getPropertyValue('width').replace('px', ''), 10); @@ -653,7 +653,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } public registerLinkMatcher(regex: RegExp, handler: (url: string) => void, matchIndex?: number, validationCallback?: (uri: string, callback: (isValid: boolean) => void) => void): number { - return this._linkHandler!.registerCustomLinkHandler(regex, handler, matchIndex, validationCallback); + return this._linkManager!.registerCustomLinkHandler(regex, handler, matchIndex, validationCallback); } public deregisterLinkMatcher(linkMatcherId: number): void { @@ -715,8 +715,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { dispose(this._windowsShellHelper); this._windowsShellHelper = undefined; - dispose(this._linkHandler); - this._linkHandler = undefined; + dispose(this._linkManager); + this._linkManager = undefined; dispose(this._commandTrackerAddon); this._commandTrackerAddon = undefined; dispose(this._widgetManager); @@ -1118,8 +1118,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private async _updateProcessCwd(): Promise { // reset cwd if it has changed, so file based url paths can be resolved const cwd = await this.getCwd(); - if (cwd && this._linkHandler) { - this._linkHandler.processCwd = cwd; + if (cwd && this._linkManager) { + this._linkManager.processCwd = cwd; } return cwd; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts b/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts deleted file mode 100644 index b15ac750d0c46..0000000000000 --- a/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts +++ /dev/null @@ -1,192 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Terminal, IViewportRange, ILinkProvider, IBufferCellPosition, ILink, IBufferRange, IBuffer, IBufferLine } from 'xterm'; -import { ILinkComputerTarget, LinkComputer } from 'vs/editor/common/modes/linkComputer'; -import { IRange } from 'vs/editor/common/core/range'; - - -export class TerminalWebLinkProvider implements ILinkProvider { - private _linkComputerTarget: ILinkComputerTarget | undefined; - - constructor( - private readonly _xterm: Terminal, - private readonly _activateCallback: (event: MouseEvent, uri: string) => void, - private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange) => boolean | void, - private readonly _leaveCallback: () => 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++; - } - - this._linkComputerTarget = new TerminalLinkAdapter(this._xterm, startLine, endLine); - const links = LinkComputer.computeLinks(this._linkComputerTarget); - - let found = false; - links.forEach(link => { - const range = convertLinkRangeToBuffer(lines, this._xterm.cols, link.range, startLine); - - // Check if the link if within the mouse position - if (this._positionIsInRange(position, range)) { - found = true; - - callback({ - text: link.url?.toString() || '', - range, - activate: (event: MouseEvent, text: string) => { - this._activateCallback(event, text); - }, - hover: (event: MouseEvent, text: string) => { - setTimeout(() => { - this._tooltipCallback(event, text, convertBufferRangeToViewport(range, this._xterm.buffer.active.viewportY)); - }, 200); - }, - leave: () => { - this._leaveCallback(); - } - }); - } - }); - - if (!found) { - callback(undefined); - } - } - - private _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; - } -} - -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 width = line.getCell(x)?.getWidth(); - if (width === 2) { - lineOffset++; - } - } - 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 = 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; -} - -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 - } - }; -} - -class TerminalLinkAdapter implements ILinkComputerTarget { - constructor( - private _xterm: Terminal, - private _lineStart: number, - private _lineEnd: number - ) { } - - getLineCount(): number { - return 1; - } - - getLineContent(): string { - return getXtermLineContent(this._xterm.buffer.active, this._lineStart, this._lineEnd); - } -} - -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; -} diff --git a/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkHelpers.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkHelpers.test.ts new file mode 100644 index 0000000000000..af67b867c1a7b --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkHelpers.test.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { IBufferLine, IBufferCell } from 'xterm'; +import { convertLinkRangeToBuffer } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers'; + +suite('Workbench - Terminal Link Helpers', () => { + suite('convertLinkRangeToBuffer', () => { + test('should convert ranges for ascii characters', () => { + const lines = createBufferLineArray([ + { text: 'AA http://t', width: 11 }, + { text: '.com/f/', width: 8 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 1 }, + end: { x: 7, y: 2 } + }); + }); + test('should convert ranges for wide characters before the link', () => { + const lines = createBufferLineArray([ + { text: 'A文 http://', width: 11 }, + { text: 't.com/f/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4 + 1, y: 1 }, + end: { x: 7 + 1, y: 2 } + }); + }); + test('should convert ranges for combining characters before the link', () => { + const lines = createBufferLineArray([ + { text: 'A🙂 http://', width: 11 }, + { text: 't.com/f/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4 + 1, startLineNumber: 1, endColumn: 19 + 1, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 1 }, + end: { x: 7, y: 2 } + }); + }); + test('should convert ranges for wide characters inside the link', () => { + const lines = createBufferLineArray([ + { text: 'AA http://t', width: 11 }, + { text: '.com/文/', width: 8 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 1 }, + end: { x: 7 + 1, y: 2 } + }); + }); + test('should convert ranges for wide characters before and inside the link', () => { + const lines = createBufferLineArray([ + { text: 'A文 http://', width: 11 }, + { text: 't.com/文/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4 + 1, y: 1 }, + end: { x: 7 + 2, y: 2 } + }); + }); + test('should convert ranges for emoji before before and wide inside the link', () => { + const lines = createBufferLineArray([ + { text: 'A🙂 http://', width: 11 }, + { text: 't.com/文/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4 + 1, startLineNumber: 1, endColumn: 19 + 1, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 1 }, + end: { x: 7 + 1, y: 2 } + }); + }); + test('should convert ranges for ascii characters (link starts on wrapped)', () => { + const lines = createBufferLineArray([ + { text: 'AAAAAAAAAAA', width: 11 }, + { text: 'AA http://t', width: 11 }, + { text: '.com/f/', width: 8 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 2 }, + end: { x: 7, y: 3 } + }); + }); + test('should convert ranges for wide characters before the link (link starts on wrapped)', () => { + const lines = createBufferLineArray([ + { text: 'AAAAAAAAAAA', width: 11 }, + { text: 'A文 http://', width: 11 }, + { text: 't.com/f/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4 + 1, y: 2 }, + end: { x: 7 + 1, y: 3 } + }); + }); + test('should convert ranges for wide characters inside the link (link starts on wrapped)', () => { + const lines = createBufferLineArray([ + { text: 'AAAAAAAAAAA', width: 11 }, + { text: 'AA http://t', width: 11 }, + { text: '.com/文/', width: 8 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4, y: 2 }, + end: { x: 7 + 1, y: 3 } + }); + }); + test('should convert ranges for wide characters before and inside the link', () => { + const lines = createBufferLineArray([ + { text: 'AAAAAAAAAAA', width: 11 }, + { text: 'A文 http://', width: 11 }, + { text: 't.com/文/', width: 9 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); + assert.deepEqual(bufferRange, { + start: { x: 4 + 1, y: 2 }, + end: { x: 7 + 2, y: 3 } + }); + }); + test('should convert ranges for several wide characters before the link', () => { + const lines = createBufferLineArray([ + { text: 'A文文AAAAAA', width: 11 }, + { text: 'AA文文 http', width: 11 }, + { text: '://t.com/f/', width: 11 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); + // This test ensures that the start offset is applies to the end before it's counted + assert.deepEqual(bufferRange, { + start: { x: 4 + 4, y: 2 }, + end: { x: 7 + 4, y: 3 } + }); + }); + test('should convert ranges for several wide characters before and inside the link', () => { + const lines = createBufferLineArray([ + { text: 'A文文AAAAAA', width: 11 }, + { text: 'AA文文 http', width: 11 }, + { text: '://t.com/文', width: 11 }, + { text: '文/', width: 3 } + ]); + const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 31, endLineNumber: 1 }, 0); + // This test ensures that the start offset is applies to the end before it's counted + assert.deepEqual(bufferRange, { + start: { x: 4 + 4, y: 2 }, + end: { x: 2, y: 4 } + }); + }); + }); +}); + +const TEST_WIDE_CHAR = '文'; +const TEST_NULL_CHAR = 'C'; + +function createBufferLineArray(lines: { text: string, width: number }[]): IBufferLine[] { + let result: IBufferLine[] = []; + lines.forEach((l, i) => { + result.push(new TestBufferLine( + l.text, + l.width, + i + 1 !== lines.length + )); + }); + return result; +} + +class TestBufferLine implements IBufferLine { + constructor( + private _text: string, + public length: number, + public isWrapped: boolean + ) { + + } + getCell(x: number): IBufferCell | undefined { + // Create a fake line of cells and use that to resolve the width + let cells: string[] = []; + let wideNullCellOffset = 0; // There is no null 0 width char after a wide char + let emojiOffset = 0; // Skip chars as emoji are multiple characters + for (let i = 0; i <= x - wideNullCellOffset + emojiOffset; i++) { + let char = this._text.charAt(i); + if (char === '\ud83d') { + // Make "🙂" + char += '\ude42'; + } + cells.push(char); + if (this._text.charAt(i) === TEST_WIDE_CHAR) { + // Skip the next character as it's width is 0 + cells.push(TEST_NULL_CHAR); + wideNullCellOffset++; + } + } + return { + getChars: () => { + return x >= cells.length ? '' : cells[x]; + }, + getWidth: () => { + switch (cells[x]) { + case TEST_WIDE_CHAR: return 2; + case TEST_NULL_CHAR: return 0; + default: return 1; + } + } + } as any; + } + translateToString(): string { + throw new Error('Method not implemented.'); + } +} diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalLinkHandler.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkManager.test.ts similarity index 91% rename from src/vs/workbench/contrib/terminal/test/browser/terminalLinkHandler.test.ts rename to src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkManager.test.ts index f817d6bb400a7..ee739f8b562fd 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalLinkHandler.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkManager.test.ts @@ -5,14 +5,14 @@ import * as assert from 'assert'; import { OperatingSystem } from 'vs/base/common/platform'; -import { TerminalLinkHandler, LineColumnInfo, XtermLinkMatcherHandler } from 'vs/workbench/contrib/terminal/browser/terminalLinkHandler'; +import { TerminalLinkManager, LineColumnInfo, XtermLinkMatcherHandler } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager'; import * as strings from 'vs/base/common/strings'; import { ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { Event } from 'vs/base/common/event'; import { ITerminalConfigHelper } from 'vs/workbench/contrib/terminal/common/terminal'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; -class TestTerminalLinkHandler extends TerminalLinkHandler { +class TestTerminalLinkManager extends TerminalLinkManager { public get localLinkRegex(): RegExp { return this._localLinkRegex; } @@ -29,7 +29,7 @@ class TestTerminalLinkHandler extends TerminalLinkHandler { return true; } public wrapLinkHandler(handler: (link: string) => void): XtermLinkMatcherHandler { - TerminalLinkHandler._LINK_INTERCEPT_THRESHOLD = 0; + TerminalLinkManager._LINK_INTERCEPT_THRESHOLD = 0; return this._wrapLinkHandler(handler); } } @@ -86,10 +86,10 @@ const testConfigHelper: ITerminalConfigHelper = { suite('Workbench - TerminalLinkHandler', () => { suite('localLinkRegex', () => { test('Windows', () => { - const terminalLinkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const terminalLinkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Windows, userHome: '' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); function testLink(link: string, linkUrl: string, lineNo?: string, columnNo?: string) { assert.equal(terminalLinkHandler.extractLinkUrl(link), linkUrl); assert.equal(terminalLinkHandler.extractLinkUrl(`:${link}:`), linkUrl); @@ -162,10 +162,10 @@ suite('Workbench - TerminalLinkHandler', () => { }); test('Linux', () => { - const terminalLinkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const terminalLinkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Linux, userHome: '' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); function testLink(link: string, linkUrl: string, lineNo?: string, columnNo?: string) { assert.equal(terminalLinkHandler.extractLinkUrl(link), linkUrl); assert.equal(terminalLinkHandler.extractLinkUrl(`:${link}:`), linkUrl); @@ -230,10 +230,10 @@ suite('Workbench - TerminalLinkHandler', () => { suite('preprocessPath', () => { test('Windows', () => { - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Windows, userHome: 'C:\\Users\\Me' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); linkHandler.processCwd = 'C:\\base'; assert.equal(linkHandler.preprocessPath('./src/file1'), 'C:\\base\\src\\file1'); @@ -243,10 +243,10 @@ suite('Workbench - TerminalLinkHandler', () => { assert.equal(linkHandler.preprocessPath('C:\\absolute\\path\\file5'), 'C:\\absolute\\path\\file5'); }); test('Windows - spaces', () => { - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Windows, userHome: 'C:\\Users\\M e' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); linkHandler.processCwd = 'C:\\base dir'; assert.equal(linkHandler.preprocessPath('./src/file1'), 'C:\\base dir\\src\\file1'); @@ -257,10 +257,10 @@ suite('Workbench - TerminalLinkHandler', () => { }); test('Linux', () => { - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Linux, userHome: '/home/me' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); linkHandler.processCwd = '/base'; assert.equal(linkHandler.preprocessPath('./src/file1'), '/base/src/file1'); @@ -270,10 +270,10 @@ suite('Workbench - TerminalLinkHandler', () => { }); test('No Workspace', () => { - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Linux, userHome: '/home/me' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); assert.equal(linkHandler.preprocessPath('./src/file1'), null); assert.equal(linkHandler.preprocessPath('src/file2'), null); @@ -284,10 +284,10 @@ suite('Workbench - TerminalLinkHandler', () => { test('gitDiffLinkRegex', () => { // The platform is irrelevant because the links generated by Git are the same format regardless of platform - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Linux, userHome: '' - } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, null!, new MockTerminalInstanceService(), null!, null!, null!, null!); function assertAreGoodMatches(matches: RegExpMatchArray | null) { if (matches) { @@ -315,10 +315,10 @@ suite('Workbench - TerminalLinkHandler', () => { const nullMouseEvent: any = Object.freeze({ preventDefault: () => { } }); test('should allow intercepting of links with onBeforeHandleLink', async () => { - const linkHandler = new TestTerminalLinkHandler(new TestXterm() as any, { + const linkHandler = new TestTerminalLinkManager(new TestXterm() as any, { os: OperatingSystem.Linux, userHome: '' - } as any, testConfigHelper, null!, null!, new TestConfigurationService(), new MockTerminalInstanceService(), null!, null!); + } as any, testConfigHelper, null!, null!, new TestConfigurationService(), new MockTerminalInstanceService(), null!, null!, null!, null!); linkHandler.onBeforeHandleLink(e => { if (e.link === 'https://www.microsoft.com') { intercepted = true; diff --git a/src/vs/workbench/contrib/terminal/test/browser/links/terminalValidatedLocalLinkProvider.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalValidatedLocalLinkProvider.test.ts new file mode 100644 index 0000000000000..3c63cde4bab61 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalValidatedLocalLinkProvider.test.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { TerminalValidatedLocalLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider'; +import { Terminal, ILink, IBufferRange, IBufferCellPosition } from 'xterm'; +import { OperatingSystem } from 'vs/base/common/platform'; +import { format } from 'vs/base/common/strings'; + +const unixLinks = [ + '/foo', + '~/foo', + './foo', + '../foo', + '/foo/bar', + 'foo/bar' +]; + +const windowsLinks = [ + 'c:\\foo', + 'c:/foo', + '.\\foo', + './foo', + '..\\foo', + '~\\foo', + '~/foo', + 'c:/foo/bar', + 'c:\\foo\\bar', + 'c:\\foo/bar\\baz', + 'foo/bar', + 'foo/bar', + 'foo\\bar' +]; + +interface LinkFormatInfo { + urlFormat: string; + line?: string; + column?: string; +} + +const supportedLinkFormats: LinkFormatInfo[] = [ + { urlFormat: '{0}' }, + { urlFormat: '{0} on line {1}', line: '5' }, + { urlFormat: '{0} on line {1}, column {2}', line: '5', column: '3' }, + { urlFormat: '{0}:line {1}', line: '5' }, + { urlFormat: '{0}:line {1}, column {2}', line: '5', column: '3' }, + { urlFormat: '{0}({1})', line: '5' }, + { urlFormat: '{0} ({1})', line: '5' }, + { urlFormat: '{0}({1},{2})', line: '5', column: '3' }, + { urlFormat: '{0} ({1},{2})', line: '5', column: '3' }, + { urlFormat: '{0}({1}, {2})', line: '5', column: '3' }, + { urlFormat: '{0} ({1}, {2})', line: '5', column: '3' }, + { urlFormat: '{0}:{1}', line: '5' }, + { urlFormat: '{0}:{1}:{2}', line: '5', column: '3' }, + { urlFormat: '{0}[{1}]', line: '5' }, + { urlFormat: '{0} [{1}]', line: '5' }, + { urlFormat: '{0}[{1},{2}]', line: '5', column: '3' }, + { urlFormat: '{0} [{1},{2}]', line: '5', column: '3' }, + { urlFormat: '{0}[{1}, {2}]', line: '5', column: '3' }, + { urlFormat: '{0} [{1}, {2}]', line: '5', column: '3' }, + { urlFormat: '{0}",{1}', line: '5' } +]; + +suite('Workbench - TerminalValidatedLocalLinkProvider', () => { + async function assertLink(text: string, os: OperatingSystem, expected: { text: string, range: [number, number][] }) { + const xterm = new Terminal(); + const provider = new TerminalValidatedLocalLinkProvider(xterm, os, () => { }, () => { }, () => { }, (_, cb) => { cb(true); }); + + // Write the text and wait for the parser to finish + await new Promise(r => xterm.write(text, r)); + + // Calculate positions just outside of link boundaries + const noLinkPositions: IBufferCellPosition[] = [ + { x: expected.range[0][0] - 1, y: expected.range[0][1] }, + { x: expected.range[1][0] + 1, y: expected.range[1][1] } + ]; + + // Ensure outside positions do not detect the link + for (let i = 0; i < noLinkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(noLinkPositions[i], r)); + assert.equal(link, undefined, `Just outside range boundary should not result in link, link found at (${link?.range.start.x}, ${link?.range.start.y}) to (${link?.range.end.x}, ${link?.range.end.y}) while checking (${noLinkPositions[i].x}, ${noLinkPositions[i].y})\nExpected link text=${expected.text}\nActual link text=${link?.text}`); + } + + // Convert range from [[startx, starty], [endx, endy]] to an IBufferRange + const linkRange: IBufferRange = { + start: { x: expected.range[0][0], y: expected.range[0][1] }, + end: { x: expected.range[1][0], y: expected.range[1][1] }, + }; + + // Calculate positions inside the link boundaries + const linkPositions: IBufferCellPosition[] = [ + linkRange.start, + linkRange.end + ]; + + // Ensure inside positions do detect the link + for (let i = 0; i < linkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(linkPositions[i], r)); + assert.deepEqual(link?.text, expected.text); + assert.deepEqual(link?.range, linkRange); + } + } + + suite('Linux/macOS', () => { + unixLinks.forEach(baseLink => { + suite(`Link: ${baseLink}`, () => { + for (let i = 0; i < supportedLinkFormats.length; i++) { + const linkFormat = supportedLinkFormats[i]; + test(`Format: ${linkFormat.urlFormat}`, async () => { + const formattedLink = format(linkFormat.urlFormat, baseLink, linkFormat.line, linkFormat.column); + await assertLink(formattedLink, OperatingSystem.Linux, { text: formattedLink, range: [[1, 1], [formattedLink.length, 1]] }); + await assertLink(` ${formattedLink} `, OperatingSystem.Linux, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + await assertLink(`(${formattedLink})`, OperatingSystem.Linux, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + await assertLink(`[${formattedLink}]`, OperatingSystem.Linux, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + }); + } + }); + }); + }); + + suite('Windows', () => { + windowsLinks.forEach(baseLink => { + suite(`Link "${baseLink}"`, () => { + for (let i = 0; i < supportedLinkFormats.length; i++) { + const linkFormat = supportedLinkFormats[i]; + test(`Format: ${linkFormat.urlFormat}`, async () => { + const formattedLink = format(linkFormat.urlFormat, baseLink, linkFormat.line, linkFormat.column); + await assertLink(formattedLink, OperatingSystem.Windows, { text: formattedLink, range: [[1, 1], [formattedLink.length, 1]] }); + await assertLink(` ${formattedLink} `, OperatingSystem.Windows, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + await assertLink(`(${formattedLink})`, OperatingSystem.Windows, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + await assertLink(`[${formattedLink}]`, OperatingSystem.Windows, { text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }); + }); + } + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/links/terminalWebLinkProvider.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalWebLinkProvider.test.ts new file mode 100644 index 0000000000000..15e562586621e --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalWebLinkProvider.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { TerminalWebLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalWebLinkProvider'; +import { Terminal, ILink, IBufferRange, IBufferCellPosition } from 'xterm'; + +suite('Workbench - TerminalWebLinkProvider', () => { + async function assertLink(text: string, expected: { text: string, range: [number, number][] }) { + const xterm = new Terminal(); + const provider = new TerminalWebLinkProvider(xterm, () => { }, () => { }, () => { }); + + // Write the text and wait for the parser to finish + await new Promise(r => xterm.write(text, r)); + + // Calculate positions just outside of link boundaries + const noLinkPositions: IBufferCellPosition[] = [ + { x: expected.range[0][0] - 1, y: expected.range[0][1] }, + { x: expected.range[1][0] + 1, y: expected.range[1][1] } + ]; + + // Ensure outside positions do not detect the link + for (let i = 0; i < noLinkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(noLinkPositions[i], r)); + assert.equal(link, undefined, `Just outside range boundary should not result in link, link found at (${link?.range.start.x}, ${link?.range.start.y}) to (${link?.range.end.x}, ${link?.range.end.y}) while checking (${noLinkPositions[i].x}, ${noLinkPositions[i].y})\nExpected link text=${expected.text}\nActual link text=${link?.text}`); + } + + // Convert range from [[startx, starty], [endx, endy]] to an IBufferRange + const linkRange: IBufferRange = { + start: { x: expected.range[0][0], y: expected.range[0][1] }, + end: { x: expected.range[1][0], y: expected.range[1][1] }, + }; + + // Calculate positions inside the link boundaries + const linkPositions: IBufferCellPosition[] = [ + linkRange.start, + linkRange.end + ]; + + // Ensure inside positions do detect the link + for (let i = 0; i < linkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(linkPositions[i], r)); + assert.deepEqual(link?.text, expected.text); + assert.deepEqual(link?.range, linkRange); + } + } + + // These tests are based on LinkComputer.test.ts + test('LinkComputer cases', async () => { + await assertLink('x = "http://foo.bar";', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('x = (http://foo.bar);', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('x = \'http://foo.bar\';', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('x = http://foo.bar ;', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('x = ;', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('x = {http://foo.bar};', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('(see http://foo.bar)', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('[see http://foo.bar]', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('{see http://foo.bar}', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('http://foo.bar', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); + await assertLink('// Click here to learn more. https://go.microsoft.com/fwlink/?LinkID=513275&clcid=0x409', { range: [[30, 1], [7, 2]], text: 'https://go.microsoft.com/fwlink/?LinkID=513275&clcid=0x409' }); + await assertLink('// Click here to learn more. https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx', { range: [[30, 1], [28, 2]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx' }); + await assertLink('// https://github.com/projectkudu/kudu/blob/master/Kudu.Core/Scripts/selectNodeVersion.js', { range: [[4, 1], [9, 2]], text: 'https://github.com/projectkudu/kudu/blob/master/Kudu.Core/Scripts/selectNodeVersion.js' }); + await assertLink('', { range: [[49, 1], [14, 2]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }); + await assertLink('For instructions, see https://go.microsoft.com/fwlink/?LinkId=166007.', { range: [[23, 1], [68, 1]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }); + await assertLink('For instructions, see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx.', { range: [[23, 1], [21, 2]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx' }); + await assertLink('x = "https://en.wikipedia.org/wiki/Zürich";', { range: [[6, 1], [41, 1]], text: 'https://en.wikipedia.org/wiki/Zürich' }); + await assertLink('請參閱 http://go.microsoft.com/fwlink/?LinkId=761051。', { range: [[8, 1], [53, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }); + await assertLink('(請參閱 http://go.microsoft.com/fwlink/?LinkId=761051)', { range: [[10, 1], [55, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }); + await assertLink('x = "file:///foo.bar";', { range: [[6, 1], [20, 1]], text: 'file:///foo.bar' }); + await assertLink('x = "file://c:/foo.bar";', { range: [[6, 1], [22, 1]], text: 'file://c:/foo.bar' }); + await assertLink('x = "file://shares/foo.bar";', { range: [[6, 1], [26, 1]], text: 'file://shares/foo.bar' }); + await assertLink('x = "file://shäres/foo.bar";', { range: [[6, 1], [26, 1]], text: 'file://shäres/foo.bar' }); + await assertLink('Some text, then http://www.bing.com.', { range: [[17, 1], [35, 1]], text: 'http://www.bing.com' }); + await assertLink('let url = `http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items`;', { range: [[12, 1], [78, 1]], text: 'http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items' }); + await assertLink('7. At this point, ServiceMain has been called. There is no functionality presently in ServiceMain, but you can consult the [MSDN documentation](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687414(v=vs.85).aspx) to add functionality as desired!', { range: [[66, 2], [64, 3]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/ms687414(v=vs.85).aspx' }); + await assertLink('let x = "http://[::1]:5000/connect/token"', { range: [[10, 1], [40, 1]], text: 'http://[::1]:5000/connect/token' }); + await assertLink('2. Navigate to **https://portal.azure.com**', { range: [[18, 1], [41, 1]], text: 'https://portal.azure.com' }); + await assertLink('POST|https://portal.azure.com|2019-12-05|', { range: [[6, 1], [29, 1]], text: 'https://portal.azure.com' }); + await assertLink('aa https://foo.bar/[this is foo site] aa', { range: [[5, 1], [38, 1]], text: 'https://foo.bar/[this is foo site]' }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/links/terminalWordLinkProvider.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalWordLinkProvider.test.ts new file mode 100644 index 0000000000000..1c137d3c8ae99 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalWordLinkProvider.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Terminal, ILink, IBufferRange, IBufferCellPosition } from 'xterm'; +import { TerminalWordLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; + +suite('Workbench - TerminalWordLinkProvider', () => { + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + + setup(() => { + instantiationService = new TestInstantiationService(); + configurationService = new TestConfigurationService(); + instantiationService.stub(IConfigurationService, configurationService); + }); + + async function assertLink(text: string, expected: { text: string, range: [number, number][] }) { + const xterm = new Terminal(); + const provider = instantiationService.createInstance(TerminalWordLinkProvider, xterm, () => { }, () => { }, () => { }); + + // Write the text and wait for the parser to finish + await new Promise(r => xterm.write(text, r)); + + // Calculate positions just outside of link boundaries + const noLinkPositions: IBufferCellPosition[] = [ + { x: expected.range[0][0] - 1, y: expected.range[0][1] }, + { x: expected.range[1][0] + 1, y: expected.range[1][1] } + ]; + + // Ensure outside positions do not detect the link + for (let i = 0; i < noLinkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(noLinkPositions[i], r)); + assert.equal(link, undefined, `Just outside range boundary should not result in link, link found at (${link?.range.start.x}, ${link?.range.start.y}) to (${link?.range.end.x}, ${link?.range.end.y}) while checking (${noLinkPositions[i].x}, ${noLinkPositions[i].y})\nExpected link text=${expected.text}\nActual link text=${link?.text}`); + } + + // Convert range from [[startx, starty], [endx, endy]] to an IBufferRange + const linkRange: IBufferRange = { + start: { x: expected.range[0][0], y: expected.range[0][1] }, + end: { x: expected.range[1][0], y: expected.range[1][1] }, + }; + + // Calculate positions inside the link boundaries + const linkPositions: IBufferCellPosition[] = [ + linkRange.start, + linkRange.end + ]; + + // Ensure inside positions do detect the link + for (let i = 0; i < linkPositions.length; i++) { + const link = await new Promise(r => provider.provideLink(linkPositions[i], r)); + assert.deepEqual(link?.text, expected.text); + assert.deepEqual(link?.range, linkRange); + } + } + + test('should link words as defined by wordSeparators', async () => { + await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ()[]' } }); + await assertLink('foo', { range: [[1, 1], [3, 1]], text: 'foo' }); + await assertLink(' foo ', { range: [[2, 1], [4, 1]], text: 'foo' }); + await assertLink('(foo)', { range: [[2, 1], [4, 1]], text: 'foo' }); + await assertLink('[foo]', { range: [[2, 1], [4, 1]], text: 'foo' }); + await assertLink('{foo}', { range: [[1, 1], [5, 1]], text: '{foo}' }); + + await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } }); + await assertLink('foo', { range: [[1, 1], [3, 1]], text: 'foo' }); + await assertLink(' foo ', { range: [[2, 1], [4, 1]], text: 'foo' }); + await assertLink('(foo)', { range: [[1, 1], [5, 1]], text: '(foo)' }); + await assertLink('[foo]', { range: [[1, 1], [5, 1]], text: '[foo]' }); + await assertLink('{foo}', { range: [[1, 1], [5, 1]], text: '{foo}' }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts deleted file mode 100644 index 88acc527444aa..0000000000000 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import { convertLinkRangeToBuffer, TerminalWebLinkProvider } from 'vs/workbench/contrib/terminal/browser/terminalWebLinkProvider'; -import { IBufferLine, IBufferCell, Terminal, ILink, IBufferRange, IBufferCellPosition } from 'xterm'; - -suite('Workbench - TerminalWebLinkProvider', () => { - suite('TerminalWebLinkProvider', () => { - async function assertLink(text: string, expected: { text: string, range: [number, number][] }) { - const xterm = new Terminal(); - const provider = new TerminalWebLinkProvider(xterm, () => { }, () => { }, () => { }); - - // Write the text and wait for the parser to finish - await new Promise(r => xterm.write(text, r)); - - // Calculate positions just outside of link boundaries - const noLinkPositions: IBufferCellPosition[] = [ - { x: expected.range[0][0] - 1, y: expected.range[0][1] }, - { x: expected.range[1][0] + 1, y: expected.range[1][1] } - ]; - - // Ensure outside positions do not detect the link - for (let i = 0; i < noLinkPositions.length; i++) { - const link = await new Promise(r => provider.provideLink(noLinkPositions[i], r)); - assert.equal(link, undefined, `Just outside range boundary should not result in link, link found at: (${link?.range.start.x}, ${link?.range.start.y}) to (${link?.range.end.x}, ${link?.range.end.y})`); - } - - // Convert range from [[startx, starty], [endx, endy]] to an IBufferRange - const linkRange: IBufferRange = { - start: { x: expected.range[0][0], y: expected.range[0][1] }, - end: { x: expected.range[1][0], y: expected.range[1][1] }, - }; - - // Calculate positions inside the link boundaries - const linkPositions: IBufferCellPosition[] = [ - linkRange.start, - linkRange.end - ]; - - // Ensure inside positions do detect the link - for (let i = 0; i < linkPositions.length; i++) { - const link = await new Promise(r => provider.provideLink(linkPositions[i], r)); - - assert.ok(link); - assert.deepEqual(link!.text, expected.text); - assert.deepEqual(link!.range, linkRange); - } - } - - // These tests are based on LinkComputer.test.ts - test('LinkComputer cases', async () => { - await assertLink('x = "http://foo.bar";', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('x = (http://foo.bar);', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('x = \'http://foo.bar\';', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('x = http://foo.bar ;', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('x = ;', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('x = {http://foo.bar};', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('(see http://foo.bar)', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('[see http://foo.bar]', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('{see http://foo.bar}', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('http://foo.bar', { range: [[6, 1], [19, 1]], text: 'http://foo.bar' }); - await assertLink('// Click here to learn more. https://go.microsoft.com/fwlink/?LinkID=513275&clcid=0x409', { range: [[30, 1], [7, 2]], text: 'https://go.microsoft.com/fwlink/?LinkID=513275&clcid=0x409' }); - await assertLink('// Click here to learn more. https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx', { range: [[30, 1], [28, 2]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx' }); - await assertLink('// https://github.com/projectkudu/kudu/blob/master/Kudu.Core/Scripts/selectNodeVersion.js', { range: [[4, 1], [9, 2]], text: 'https://github.com/projectkudu/kudu/blob/master/Kudu.Core/Scripts/selectNodeVersion.js' }); - await assertLink('', { range: [[49, 1], [14, 2]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }); - await assertLink('For instructions, see https://go.microsoft.com/fwlink/?LinkId=166007.', { range: [[23, 1], [68, 1]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }); - await assertLink('For instructions, see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx.', { range: [[23, 1], [21, 2]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx' }); - await assertLink('x = "https://en.wikipedia.org/wiki/Zürich";', { range: [[6, 1], [41, 1]], text: 'https://en.wikipedia.org/wiki/Zürich' }); - await assertLink('請參閱 http://go.microsoft.com/fwlink/?LinkId=761051。', { range: [[8, 1], [53, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }); - await assertLink('(請參閱 http://go.microsoft.com/fwlink/?LinkId=761051)', { range: [[10, 1], [55, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }); - await assertLink('x = "file:///foo.bar";', { range: [[6, 1], [20, 1]], text: 'file:///foo.bar' }); - await assertLink('x = "file://c:/foo.bar";', { range: [[6, 1], [22, 1]], text: 'file://c:/foo.bar' }); - await assertLink('x = "file://shares/foo.bar";', { range: [[6, 1], [26, 1]], text: 'file://shares/foo.bar' }); - await assertLink('x = "file://shäres/foo.bar";', { range: [[6, 1], [26, 1]], text: 'file://shäres/foo.bar' }); - await assertLink('Some text, then http://www.bing.com.', { range: [[17, 1], [35, 1]], text: 'http://www.bing.com' }); - await assertLink('let url = `http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items`;', { range: [[12, 1], [78, 1]], text: 'http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items' }); - await assertLink('7. At this point, ServiceMain has been called. There is no functionality presently in ServiceMain, but you can consult the [MSDN documentation](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687414(v=vs.85).aspx) to add functionality as desired!', { range: [[66, 2], [64, 3]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/ms687414(v=vs.85).aspx' }); - await assertLink('let x = "http://[::1]:5000/connect/token"', { range: [[10, 1], [40, 1]], text: 'http://[::1]:5000/connect/token' }); - await assertLink('2. Navigate to **https://portal.azure.com**', { range: [[18, 1], [41, 1]], text: 'https://portal.azure.com' }); - await assertLink('POST|https://portal.azure.com|2019-12-05|', { range: [[6, 1], [29, 1]], text: 'https://portal.azure.com' }); - await assertLink('aa https://foo.bar/[this is foo site] aa', { range: [[5, 1], [38, 1]], text: 'https://foo.bar/[this is foo site]' }); - }); - }); - suite('convertLinkRangeToBuffer', () => { - test('should convert ranges for ascii characters', () => { - const lines = createBufferLineArray([ - { text: 'AA http://t', width: 11 }, - { text: '.com/f/', width: 8 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4, y: 1 }, - end: { x: 7, y: 2 } - }); - }); - test('should convert ranges for wide characters before the link', () => { - const lines = createBufferLineArray([ - { text: 'A文 http://', width: 11 }, - { text: 't.com/f/', width: 9 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4 + 1, y: 1 }, - end: { x: 7 + 1, y: 2 } - }); - }); - test('should convert ranges for wide characters inside the link', () => { - const lines = createBufferLineArray([ - { text: 'AA http://t', width: 11 }, - { text: '.com/文/', width: 8 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4, y: 1 }, - end: { x: 7 + 1, y: 2 } - }); - }); - test('should convert ranges for wide characters before and inside the link', () => { - const lines = createBufferLineArray([ - { text: 'A文 http://', width: 11 }, - { text: 't.com/文/', width: 9 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 4, startLineNumber: 1, endColumn: 19, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4 + 1, y: 1 }, - end: { x: 7 + 2, y: 2 } - }); - }); - test('should convert ranges for ascii characters (link starts on wrapped)', () => { - const lines = createBufferLineArray([ - { text: 'AAAAAAAAAAA', width: 11 }, - { text: 'AA http://t', width: 11 }, - { text: '.com/f/', width: 8 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4, y: 2 }, - end: { x: 7, y: 3 } - }); - }); - test('should convert ranges for wide characters before the link (link starts on wrapped)', () => { - const lines = createBufferLineArray([ - { text: 'AAAAAAAAAAA', width: 11 }, - { text: 'A文 http://', width: 11 }, - { text: 't.com/f/', width: 9 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4 + 1, y: 2 }, - end: { x: 7 + 1, y: 3 } - }); - }); - test('should convert ranges for wide characters inside the link (link starts on wrapped)', () => { - const lines = createBufferLineArray([ - { text: 'AAAAAAAAAAA', width: 11 }, - { text: 'AA http://t', width: 11 }, - { text: '.com/文/', width: 8 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4, y: 2 }, - end: { x: 7 + 1, y: 3 } - }); - }); - test('should convert ranges for wide characters before and inside the link', () => { - const lines = createBufferLineArray([ - { text: 'AAAAAAAAAAA', width: 11 }, - { text: 'A文 http://', width: 11 }, - { text: 't.com/文/', width: 9 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); - assert.deepEqual(bufferRange, { - start: { x: 4 + 1, y: 2 }, - end: { x: 7 + 2, y: 3 } - }); - }); - test('should convert ranges for several wide characters before the link', () => { - const lines = createBufferLineArray([ - { text: 'A文文AAAAAA', width: 11 }, - { text: 'AA文文 http', width: 11 }, - { text: '://t.com/f/', width: 11 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 30, endLineNumber: 1 }, 0); - // This test ensures that the start offset is applies to the end before it's counted - assert.deepEqual(bufferRange, { - start: { x: 4 + 4, y: 2 }, - end: { x: 7 + 4, y: 3 } - }); - }); - test('should convert ranges for several wide characters before and inside the link', () => { - const lines = createBufferLineArray([ - { text: 'A文文AAAAAA', width: 11 }, - { text: 'AA文文 http', width: 11 }, - { text: '://t.com/文', width: 11 }, - { text: '文/', width: 3 } - ]); - const bufferRange = convertLinkRangeToBuffer(lines, 11, { startColumn: 15, startLineNumber: 1, endColumn: 31, endLineNumber: 1 }, 0); - // This test ensures that the start offset is applies to the end before it's counted - assert.deepEqual(bufferRange, { - start: { x: 4 + 4, y: 2 }, - end: { x: 2, y: 4 } - }); - }); - }); -}); - -const TEST_WIDE_CHAR = '文'; -const TEST_NULL_CHAR = 'C'; - -function createBufferLineArray(lines: { text: string, width: number }[]): IBufferLine[] { - let result: IBufferLine[] = []; - lines.forEach((l, i) => { - result.push(new TestBufferLine( - l.text, - l.width, - i + 1 !== lines.length - )); - }); - return result; -} - -class TestBufferLine implements IBufferLine { - constructor( - private _text: string, - public length: number, - public isWrapped: boolean - ) { - - } - getCell(x: number): IBufferCell | undefined { - // Create a fake line of cells and use that to resolve the width - let cells: string = ''; - let offset = 0; - for (let i = 0; i <= x - offset; i++) { - const char = this._text.charAt(i); - cells += char; - if (this._text.charAt(i) === TEST_WIDE_CHAR) { - // Skip the next character as it's width is 0 - cells += TEST_NULL_CHAR; - offset++; - } - } - return { - getChars: () => { - return x >= cells.length ? '' : cells.charAt(x); - }, - getWidth: () => { - switch (cells.charAt(x)) { - case TEST_WIDE_CHAR: return 2; - case TEST_NULL_CHAR: return 0; - default: return 1; - } - } - } as any; - } - translateToString(): string { - throw new Error('Method not implemented.'); - } -}