diff --git a/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts b/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts index 4e31fc6cc02dfa0ec64bb12a3fe6430d1809e7d6..095d51371730860f35632ac64e3facf3503cbe94 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts @@ -5,7 +5,7 @@ import * as nls from 'vs/nls'; import { URI } from 'vs/base/common/uri'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable } 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'; @@ -13,7 +13,7 @@ import { ITerminalProcessManager, ITerminalConfigHelper } from 'vs/workbench/con import { ITextEditorSelection } from 'vs/platform/editor/common/editor'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IFileService } from 'vs/platform/files/common/files'; -import { Terminal, ILinkMatcherOptions, IViewportRange } from 'xterm'; +import { Terminal, ILinkMatcherOptions, IViewportRange, ITerminalAddon } from 'xterm'; 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'; @@ -21,6 +21,7 @@ import { OperatingSystem, isMacintosh } 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'; const pathPrefix = '(\\.\\.?|\\~)'; const pathSeparatorClause = '\\/'; @@ -75,6 +76,9 @@ export class TerminalLinkHandler extends DisposableStore { private _gitDiffPostImagePattern: RegExp; private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange, linkHandler: (url: string) => void) => boolean | void; private readonly _leaveCallback: () => void; + private _linkMatchers: number[] = []; + private _webLinksAddon: ITerminalAddon | undefined; + private _linkProvider: IDisposable | undefined; private _hasBeforeHandleLinkListeners = false; protected static _LINK_INTERCEPT_THRESHOLD = LINK_INTERCEPT_THRESHOLD; @@ -153,6 +157,26 @@ export class TerminalLinkHandler extends DisposableStore { } }; + if (this._configHelper.config.experimentalLinkProvider) { + this.registerLinkProvider(); + } else { + this._registerLinkMatchers(); + } + + this._configurationService?.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('terminal.integrated.experimentalLinkProvider')) { + if (this._configHelper.config.experimentalLinkProvider) { + this._deregisterLinkMatchers(); + this.registerLinkProvider(); + } else { + this._linkProvider?.dispose(); + this._registerLinkMatchers(); + } + } + }); + } + + private _registerLinkMatchers() { this.registerWebLinkHandler(); if (this._processManager) { if (this._configHelper.config.enableFileLinks) { @@ -162,6 +186,14 @@ export class TerminalLinkHandler extends DisposableStore { } } + private _deregisterLinkMatchers() { + this._webLinksAddon?.dispose(); + + this._linkMatchers.forEach(matcherId => { + this._xterm.deregisterLinkMatcher(matcherId); + }); + } + public setWidgetManager(widgetManager: TerminalWidgetManager): void { this._widgetManager = widgetManager; } @@ -192,18 +224,19 @@ export class TerminalLinkHandler extends DisposableStore { if (!this._xterm) { return; } - const wrappedHandler = this._wrapLinkHandler(uri => { - this._handleHypertextLink(uri); + const wrappedHandler = this._wrapLinkHandler(link => { + this._handleHypertextLink(link); }); const tooltipCallback = (event: MouseEvent, uri: string, location: IViewportRange) => { this._tooltipCallback(event, uri, location, this._handleHypertextLink.bind(this)); }; - this._xterm.loadAddon(new WebLinksAddon(wrappedHandler, { - validationCallback: (uri: string, callback: (isValid: boolean) => void) => this._validateWebLink(uri, callback), + this._webLinksAddon = new WebLinksAddon(wrappedHandler, { + validationCallback: (uri: string, callback: (isValid: boolean) => void) => this._validateWebLink(callback), tooltipCallback, leaveCallback: this._leaveCallback, willLinkActivate: (e: MouseEvent) => this._isLinkActivationModifierDown(e) - })); + }); + this._xterm.loadAddon(this._webLinksAddon); }); } @@ -214,13 +247,13 @@ export class TerminalLinkHandler extends DisposableStore { const tooltipCallback = (event: MouseEvent, uri: string, location: IViewportRange) => { this._tooltipCallback(event, uri, location, this._handleLocalLink.bind(this)); }; - this._xterm.registerLinkMatcher(this._localLinkRegex, wrappedHandler, { + this._linkMatchers.push(this._xterm.registerLinkMatcher(this._localLinkRegex, wrappedHandler, { validationCallback: (uri: string, callback: (isValid: boolean) => void) => this._validateLocalLink(uri, callback), tooltipCallback, leaveCallback: this._leaveCallback, willLinkActivate: (e: MouseEvent) => this._isLinkActivationModifierDown(e), priority: LOCAL_LINK_PRIORITY - }); + })); } public registerGitDiffLinkHandlers(): void { @@ -238,8 +271,17 @@ export class TerminalLinkHandler extends DisposableStore { willLinkActivate: (e: MouseEvent) => this._isLinkActivationModifierDown(e), priority: LOCAL_LINK_PRIORITY }; - this._xterm.registerLinkMatcher(this._gitDiffPreImagePattern, wrappedHandler, options); - this._xterm.registerLinkMatcher(this._gitDiffPostImagePattern, wrappedHandler, options); + this._linkMatchers.push(this._xterm.registerLinkMatcher(this._gitDiffPreImagePattern, wrappedHandler, options)); + this._linkMatchers.push(this._xterm.registerLinkMatcher(this._gitDiffPostImagePattern, wrappedHandler, options)); + } + + public registerLinkProvider(): void { + // Web links + const tooltipCallback = (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)); } protected _wrapLinkHandler(handler: (link: string) => void): XtermLinkMatcherHandler { @@ -313,7 +355,7 @@ export class TerminalLinkHandler extends DisposableStore { this._resolvePath(link).then(resolvedLink => callback(!!resolvedLink)); } - private _validateWebLink(link: string, callback: (isValid: boolean) => void): void { + private _validateWebLink(callback: (isValid: boolean) => void): void { callback(true); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts b/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..b15ac750d0c46dc868d5c8d1be6d11230549274f --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalWebLinkProvider.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * 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/browser/terminalWidgetManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalWidgetManager.ts index 39695841ba17a6ff2ff7a1c0fa6f032928df24b0..4e5acda885976a9c92ef6424979c5055820fd5ea 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalWidgetManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalWidgetManager.ts @@ -51,7 +51,7 @@ export class TerminalWidgetManager implements IDisposable { } public showMessage(left: number, y: number, text: IMarkdownString, verticalAlignment: WidgetVerticalAlignment = WidgetVerticalAlignment.Bottom, linkHandler: (url: string) => void): void { - if (!this._container) { + if (!this._container || this._messageWidget?.mouseOver) { return; } dispose(this._messageWidget); @@ -61,8 +61,9 @@ export class TerminalWidgetManager implements IDisposable { public closeMessage(): void { this._messageListeners.clear(); + const currentWidget = this._messageWidget; setTimeout(() => { - if (this._messageWidget && !this._messageWidget.mouseOver) { + if (this._messageWidget && !this._messageWidget.mouseOver && this._messageWidget === currentWidget) { this._messageListeners.add(MessageWidget.fadeOut(this._messageWidget)); } }, 50); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index dc2d844b8ae4cfd6e5927fb9939665117ea6c82e..5bd498f58fba65910921ca7f896bb66c12c0c2c0 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -131,6 +131,7 @@ export interface ITerminalConfiguration { experimentalUseTitleEvent: boolean; enableFileLinks: boolean; unicodeVersion: '6' | '11'; + experimentalLinkProvider: boolean; } export interface ITerminalConfigHelper { diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index ee9846eb41c8603789fbbda357f0661b21418837..ee8243f50bf204f675b27c1cdf81dd9926afb5c0 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -300,6 +300,11 @@ export const terminalConfiguration: IConfigurationNode = { ], default: '11', description: localize('terminal.integrated.unicodeVersion', "Controls what version of unicode to use when evaluating the width of characters in the terminal. If you experience emoji or other wide characters not taking up the right amount of space or backspace either deleting too much or too little then you may want to try tweaking this setting.") + }, + 'terminal.integrated.experimentalLinkProvider': { + description: localize('terminal.integrated.experimentalLinkProvider', "An experimental setting that aims to improve link detection in the terminal by improving when links are detected and by enabling shared link detection with the editor. Currently this only supports web links."), + type: 'boolean', + default: false } } }; diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..88acc527444aab96e8e1b687f808747faa34cbe4 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalWebLinkProvider.test.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * 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.'); + } +}