Merge pull request #141755 from microsoft/tyriar/141743

Terminal link refactors
This commit is contained in:
Daniel Imms 2022-02-02 12:59:05 -08:00 committed by GitHub
commit 1b0574e02c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1744 additions and 786 deletions

View file

@ -0,0 +1,98 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IBufferLine, IBufferRange, Terminal } from 'xterm';
import { URI } from 'vs/base/common/uri';
/**
* A link detector can search for and return links within the xterm.js buffer. A single link
* detector can return multiple links of differing types.
*/
export interface ITerminalLinkDetector {
/**
* The xterm.js instance this detector belongs to.
*/
readonly xterm: Terminal;
/**
* Detects links within the _wrapped_ line range provided and returns them as an array.
*
* @param lines The individual buffer lines that make up the wrapped line.
* @param startLine The start of the wrapped line. This _will not_ be validated that it is
* indeed the start of a wrapped line.
* @param endLine The end of the wrapped line. This _will not_ be validated that it is indeed
* the end of a wrapped line.
*/
detect(lines: IBufferLine[], startLine: number, endLine: number): ITerminalSimpleLink[] | Promise<ITerminalSimpleLink[]>;
}
export interface ITerminalSimpleLink {
/**
* The text of the link.
*/
text: string;
/**
* The URI of the link if it has been resolved.
*/
uri?: URI;
/**
* A hover label to override the default for the type.
*/
label?: string;
/**
* The buffer range of the link.
*/
readonly bufferRange: IBufferRange;
/**
* The type of link, which determines how it is handled when activated.
*/
readonly type: TerminalLinkType;
}
export type TerminalLinkType = TerminalBuiltinLinkType | ITerminalExternalLinkType;
export const enum TerminalBuiltinLinkType {
/**
* The link is validated to be a file on the file system and will open an editor.
*/
LocalFile,
/**
* The link is validated to be a folder on the file system and is outside the workspace. It will
* reveal the folder within the explorer.
*/
LocalFolderOutsideWorkspace,
/**
* The link is validated to be a folder on the file system and is within the workspace and will
* reveal the folder within the explorer.
*/
LocalFolderInWorkspace,
/**
* A low confidence link which will search for the file in the workspace. If there is a single
* match, it will open the file; otherwise, it will present the matches in a quick pick.
*/
Search,
/**
* A link whose text is a valid URI.
*/
Url
}
export interface ITerminalExternalLinkType {
id: string;
}
export interface ITerminalLinkOpener {
open(link: ITerminalSimpleLink): Promise<void>;
}
export type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => infer R ? (...args: P) => R : never;

View file

@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ITerminalLinkDetector, ITerminalSimpleLink, OmitFirstArg } from 'vs/workbench/contrib/terminal/browser/links/links';
import { convertLinkRangeToBuffer, getXtermLineContent } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { ITerminalExternalLinkProvider } from 'vs/workbench/contrib/terminal/browser/terminal';
import { IBufferLine, Terminal } from 'xterm';
const enum Constants {
/**
* The max line length to try extract word links from.
*/
MaxLineLength = 2000
}
export class TerminalExternalLinkDetector implements ITerminalLinkDetector {
constructor(
readonly id: string,
readonly xterm: Terminal,
private readonly _provideLinks: OmitFirstArg<ITerminalExternalLinkProvider['provideLinks']>
) {
}
async detect(lines: IBufferLine[], startLine: number, endLine: number): Promise<ITerminalSimpleLink[]> {
// Get the text representation of the wrapped line
const text = getXtermLineContent(this.xterm.buffer.active, startLine, endLine, this.xterm.cols);
if (text === '' || text.length > Constants.MaxLineLength) {
return [];
}
const externalLinks = await this._provideLinks(text);
if (!externalLinks) {
return [];
}
const result = externalLinks.map(link => {
const bufferRange = convertLinkRangeToBuffer(lines, this.xterm.cols, {
startColumn: link.startIndex + 1,
startLineNumber: 1,
endColumn: link.startIndex + link.length + 1,
endLineNumber: 1
}, startLine);
const matchingText = text.substring(link.startIndex, link.startIndex + link.length) || '';
const l = {
text: matchingText,
label: link.label,
bufferRange,
type: { id: this.id }
};
return l;
});
return result;
}
}

View file

@ -27,8 +27,6 @@ export class TerminalLink extends DisposableStore implements ILink {
private readonly _onInvalidated = new Emitter<void>();
get onInvalidated(): Event<void> { return this._onInvalidated.event; }
constructor(
private readonly _xterm: Terminal,
readonly range: IBufferRange,

View file

@ -0,0 +1,116 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { localize } from 'vs/nls';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ITerminalLinkDetector, ITerminalSimpleLink, TerminalBuiltinLinkType, TerminalLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalLink } from 'vs/workbench/contrib/terminal/browser/links/terminalLink';
import { XtermLinkMatcherHandler } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager';
import { IBufferLine, ILink, ILinkProvider, IViewportRange } from 'xterm';
export interface IActivateLinkEvent {
link: ITerminalSimpleLink;
event?: MouseEvent;
}
export interface IShowHoverEvent {
link: TerminalLink;
viewportRange: IViewportRange;
modifierDownCallback?: () => void;
modifierUpCallback?: () => void;
}
/**
* Wrap a link detector object so it can be used in xterm.js
*/
export class TerminalLinkDetectorAdapter extends Disposable implements ILinkProvider {
private _activeLinks: TerminalLink[] | undefined;
private readonly _onDidActivateLink = this._register(new Emitter<IActivateLinkEvent>());
readonly onDidActivateLink = this._onDidActivateLink.event;
private readonly _onDidShowHover = this._register(new Emitter<IShowHoverEvent>());
readonly onDidShowHover = this._onDidShowHover.event;
constructor(
private readonly _detector: ITerminalLinkDetector,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
}
async provideLinks(bufferLineNumber: number, callback: (links: ILink[] | undefined) => void) {
this._activeLinks?.forEach(l => l.dispose());
this._activeLinks = await this._provideLinks(bufferLineNumber);
callback(this._activeLinks);
}
private async _provideLinks(bufferLineNumber: number): Promise<TerminalLink[]> {
// Dispose of all old links if new links are provided, links are only cached for the current line
const links: TerminalLink[] = [];
let startLine = bufferLineNumber - 1;
let endLine = startLine;
const lines: IBufferLine[] = [
this._detector.xterm.buffer.active.getLine(startLine)!
];
while (startLine >= 0 && this._detector.xterm.buffer.active.getLine(startLine)?.isWrapped) {
lines.unshift(this._detector.xterm.buffer.active.getLine(startLine - 1)!);
startLine--;
}
while (endLine < this._detector.xterm.buffer.active.length && this._detector.xterm.buffer.active.getLine(endLine + 1)?.isWrapped) {
lines.push(this._detector.xterm.buffer.active.getLine(endLine + 1)!);
endLine++;
}
const detectedLinks = await this._detector.detect(lines, startLine, endLine);
for (const link of detectedLinks) {
links.push(this._createTerminalLink(link, async (event) => {
this._onDidActivateLink.fire({ link, event });
}));
}
return links;
}
private _createTerminalLink(l: ITerminalSimpleLink, activateCallback: XtermLinkMatcherHandler): TerminalLink {
// Remove trailing colon if there is one so the link is more useful
if (l.text.length > 0 && l.text.charAt(l.text.length - 1) === ':') {
l.text = l.text.slice(0, -1);
l.bufferRange.end.x--;
}
return this._instantiationService.createInstance(TerminalLink,
this._detector.xterm,
l.bufferRange,
l.text,
this._detector.xterm.buffer.active.viewportY,
activateCallback,
(link, viewportRange, modifierDownCallback, modifierUpCallback) => this._onDidShowHover.fire({
link,
viewportRange,
modifierDownCallback,
modifierUpCallback
}),
l.type !== TerminalBuiltinLinkType.Search, // Only search is low confidence
l.label || this._getLabel(l.type)
);
}
private _getLabel(type: TerminalLinkType): string {
switch (type) {
case TerminalBuiltinLinkType.Search: return localize('searchWorkspace', 'Search workspace');
case TerminalBuiltinLinkType.LocalFile: return localize('openFile', 'Open file in editor');
case TerminalBuiltinLinkType.LocalFolderInWorkspace: return localize('focusFolder', 'Focus folder in explorer');
case TerminalBuiltinLinkType.LocalFolderOutsideWorkspace: return localize('openFolder', 'Open folder in new window');
case TerminalBuiltinLinkType.Url:
default:
return localize('followLink', 'Follow link');
}
}
}

View file

@ -3,34 +3,35 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { URI } from 'vs/base/common/uri';
import { DisposableStore, IDisposable, dispose } from 'vs/base/common/lifecycle';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ITerminalProcessManager, ITerminalConfiguration, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal';
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 type { Terminal, IViewportRange, ILinkProvider, ILink } from 'xterm';
import { EventType } from 'vs/base/browser/dom';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
import { posix, win32 } from 'vs/base/common/path';
import { ITerminalExternalLinkProvider, ITerminalInstance, TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { OperatingSystem, isMacintosh, OS } from 'vs/base/common/platform';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { TerminalProtocolLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalProtocolLinkProvider';
import { TerminalValidatedLocalLinkProvider, lineAndColumnClause, unixLocalLinkClause, winLocalLinkClause, winDrivePrefix, winLineAndColumnMatchIndex, unixLineAndColumnMatchIndex, lineAndColumnClauseGroupCount } from 'vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider';
import { TerminalWordLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalWordLinkProvider';
import { isMacintosh, OperatingSystem, OS } from 'vs/base/common/platform';
import { URI } from 'vs/base/common/uri';
import * as nls from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IFileService } from 'vs/platform/files/common/files';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private';
import { TerminalHover, ILinkHoverTargetOptions } from 'vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget';
import { TerminalLink } from 'vs/workbench/contrib/terminal/browser/links/terminalLink';
import { TerminalExternalLinkProviderAdapter } from 'vs/workbench/contrib/terminal/browser/links/terminalExternalLinkProviderAdapter';
import { ILogService } from 'vs/platform/log/common/log';
import { ITunnelService } from 'vs/platform/tunnel/common/tunnel';
import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal';
import { ITerminalLinkDetector, ITerminalLinkOpener, ITerminalSimpleLink, OmitFirstArg, TerminalBuiltinLinkType, TerminalLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalExternalLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalExternalLinkDetector';
import { TerminalLink } from 'vs/workbench/contrib/terminal/browser/links/terminalLink';
import { TerminalLinkDetectorAdapter } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkDetectorAdapter';
import { TerminalLocalFileLinkOpener, TerminalLocalFolderInWorkspaceLinkOpener, TerminalLocalFolderOutsideWorkspaceLinkOpener, TerminalSearchLinkOpener, TerminalUrlLinkOpener } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkOpeners';
import { TerminalLocalLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalLocalLinkDetector';
import { TerminalUriLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalUriLinkDetector';
import { lineAndColumnClause, unixLocalLinkClause, winDrivePrefix, winLocalLinkClause } from 'vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider';
import { TerminalWordLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalWordLinkDetector';
import { ITerminalExternalLinkProvider, TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ILinkHoverTargetOptions, TerminalHover } from 'vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget';
import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager';
import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private';
import { ITerminalCapabilityStore, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { EventType } from 'vs/base/browser/dom';
import { ITerminalConfiguration, ITerminalProcessManager, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal';
import type { ILink, ILinkProvider, IViewportRange, Terminal } from 'xterm';
export type XtermLinkMatcherHandler = (event: MouseEvent | undefined, link: string) => Promise<void>;
export type XtermLinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void;
@ -47,70 +48,78 @@ interface IPath {
export class TerminalLinkManager extends DisposableStore {
private _widgetManager: TerminalWidgetManager | undefined;
private _processCwd: string | undefined;
private _standardLinkProviders: Map<string, ILinkProvider> = new Map();
private _linkProvidersDisposables: IDisposable[] = [];
private readonly _xterm: Terminal;
private readonly _standardLinkProviders: Map<string, ILinkProvider> = new Map();
private readonly _linkProvidersDisposables: IDisposable[] = [];
private readonly _externalLinkProviders: IDisposable[] = [];
private readonly _openers: Map<TerminalLinkType, ITerminalLinkOpener> = new Map();
constructor(
private _xtermTerminal: XtermTerminal,
private readonly _xterm: Terminal,
private readonly _processManager: ITerminalProcessManager,
private readonly _capabilities: ITerminalCapabilityStore,
@IOpenerService private readonly _openerService: IOpenerService,
@IEditorService private readonly _editorService: IEditorService,
capabilities: ITerminalCapabilityStore,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IFileService private readonly _fileService: IFileService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@ITunnelService private readonly _tunnelService: ITunnelService
) {
super();
this._xterm = _xtermTerminal.raw;
// Protocol links
const wrappedActivateCallback = this._wrapLinkHandler((_, link) => this._handleProtocolLink(link));
const protocolProvider = this._instantiationService.createInstance(TerminalProtocolLinkProvider,
this._xterm,
wrappedActivateCallback,
this._wrapLinkHandler.bind(this),
this._tooltipCallback.bind(this),
async (link, cb) => cb(await this._resolvePath(link)));
this._standardLinkProviders.set(TerminalProtocolLinkProvider.id, protocolProvider);
// Validated local links
// Setup link detectors in their order of priority
this._setupLinkDetector(TerminalUriLinkDetector.id, this._instantiationService.createInstance(TerminalUriLinkDetector, this._xterm, this._resolvePath.bind(this)));
if (this._configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION).enableFileLinks) {
const wrappedTextLinkActivateCallback = this._wrapLinkHandler((_, link) => this._handleLocalLink(link));
const validatedProvider = this._instantiationService.createInstance(TerminalValidatedLocalLinkProvider,
this._xterm,
this._processManager.os || OS,
wrappedTextLinkActivateCallback,
this._wrapLinkHandler.bind(this),
this._tooltipCallback.bind(this),
async (linkCandidates, cb) => {
for (const link of linkCandidates) {
const result = await this._resolvePath(link);
if (result) {
return cb(result);
}
}
return cb(undefined);
});
this._standardLinkProviders.set(TerminalValidatedLocalLinkProvider.id, validatedProvider);
this._setupLinkDetector(TerminalLocalLinkDetector.id, this._instantiationService.createInstance(TerminalLocalLinkDetector, this._xterm, this._processManager.os || OS, this._resolvePath.bind(this)));
}
this._setupLinkDetector(TerminalWordLinkDetector.id, this._instantiationService.createInstance(TerminalWordLinkDetector, this._xterm));
this._capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd(cwd => {
capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd(cwd => {
this.processCwd = cwd;
});
// Word links
const wordProvider = this._instantiationService.createInstance(TerminalWordLinkProvider, this._xtermTerminal, this._capabilities, this._wrapLinkHandler.bind(this), this._tooltipCallback.bind(this));
this._standardLinkProviders.set(TerminalWordLinkProvider.id, wordProvider);
// Setup link openers
const localFileOpener = this._instantiationService.createInstance(TerminalLocalFileLinkOpener, this._processManager.os || OS);
this._openers.set(TerminalBuiltinLinkType.LocalFile, localFileOpener);
this._openers.set(TerminalBuiltinLinkType.LocalFolderInWorkspace, this._instantiationService.createInstance(TerminalLocalFolderInWorkspaceLinkOpener));
this._openers.set(TerminalBuiltinLinkType.LocalFolderOutsideWorkspace, this._instantiationService.createInstance(TerminalLocalFolderOutsideWorkspaceLinkOpener));
this._openers.set(TerminalBuiltinLinkType.Search, this._instantiationService.createInstance(TerminalSearchLinkOpener, capabilities, localFileOpener, this._processManager.os || OS));
this._openers.set(TerminalBuiltinLinkType.Url, this._instantiationService.createInstance(TerminalUrlLinkOpener, !!this._processManager.remoteAuthority));
this._registerStandardLinkProviders();
}
private _setupLinkDetector(id: string, detector: ITerminalLinkDetector, isExternal: boolean = false): ILinkProvider {
const detectorAdapter = this._instantiationService.createInstance(TerminalLinkDetectorAdapter, detector);
detectorAdapter.onDidActivateLink(e => {
// Prevent default electron link handling so Alt+Click mode works normally
e.event?.preventDefault();
// Require correct modifier on click unless event is coming from linkQuickPick selection
if (e.event && !(e.event instanceof TerminalLinkQuickPickEvent) && !this._isLinkActivationModifierDown(e.event)) {
return;
}
// Just call the handler if there is no before listener
this._openLink(e.link);
});
detectorAdapter.onDidShowHover(e => this._tooltipCallback(e.link, e.viewportRange, e.modifierDownCallback, e.modifierUpCallback));
if (!isExternal) {
this._standardLinkProviders.set(id, detectorAdapter);
}
return detectorAdapter;
}
private async _openLink(link: ITerminalSimpleLink): Promise<void> {
this._logService.debug('Opening link', link);
const opener = this._openers.get(link.type);
if (!opener) {
throw new Error(`No matching opener for link type "${link.type}"`);
}
await opener.open(link);
}
async openRecentLink(type: 'file' | 'web'): Promise<ILink | undefined> {
let links;
let i = this._xtermTerminal.raw.buffer.active.length;
while ((!links || links.length === 0) && i >= this._xtermTerminal.raw.buffer.active.viewportY) {
links = await this.getLinksForType(i, type);
let i = this._xterm.buffer.active.length;
while ((!links || links.length === 0) && i >= this._xterm.buffer.active.viewportY) {
links = await this._getLinksForType(i, type);
i--;
}
@ -127,7 +136,7 @@ export class TerminalLinkManager extends DisposableStore {
const webResults: ILink[] = [];
const fileResults: ILink[] = [];
for (let i = this._xtermTerminal.raw.buffer.active.length - 1; i >= this._xtermTerminal.raw.buffer.active.viewportY; i--) {
for (let i = this._xterm.buffer.active.length - 1; i >= this._xterm.buffer.active.viewportY; i--) {
const links = await this._getLinksForLine(i);
if (links) {
const { wordLinks, webLinks, fileLinks } = links;
@ -146,9 +155,9 @@ export class TerminalLinkManager extends DisposableStore {
}
private async _getLinksForLine(y: number): Promise<IDetectedLinks | undefined> {
let unfilteredWordLinks = await this.getLinksForType(y, 'word');
const webLinks = await this.getLinksForType(y, 'web');
const fileLinks = await this.getLinksForType(y, 'file');
let unfilteredWordLinks = await this._getLinksForType(y, 'word');
const webLinks = await this._getLinksForType(y, 'web');
const fileLinks = await this._getLinksForType(y, 'file');
const words = new Set();
let wordLinks;
if (unfilteredWordLinks) {
@ -163,14 +172,14 @@ export class TerminalLinkManager extends DisposableStore {
return { wordLinks, webLinks, fileLinks };
}
async getLinksForType(y: number, type: 'word' | 'web' | 'file'): Promise<ILink[] | undefined> {
protected async _getLinksForType(y: number, type: 'word' | 'web' | 'file'): Promise<ILink[] | undefined> {
switch (type) {
case 'word':
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalWordLinkProvider.id)?.provideLinks(y, r)));
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalWordLinkDetector.id)?.provideLinks(y, r)));
case 'web':
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalProtocolLinkProvider.id)?.provideLinks(y, r)));
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalUriLinkDetector.id)?.provideLinks(y, r)));
case 'file':
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalValidatedLocalLinkProvider.id)?.provideLinks(y, r)));
return (await new Promise<ILink[] | undefined>(r => this._standardLinkProviders.get(TerminalLocalLinkDetector.id)?.provideLinks(y, r)));
}
}
@ -224,7 +233,7 @@ export class TerminalLinkManager extends DisposableStore {
private _clearLinkProviders(): void {
dispose(this._linkProvidersDisposables);
this._linkProvidersDisposables = [];
this._linkProvidersDisposables.length = 0;
}
private _registerStandardLinkProviders(): void {
@ -233,31 +242,17 @@ export class TerminalLinkManager extends DisposableStore {
}
}
registerExternalLinkProvider(instance: ITerminalInstance, linkProvider: ITerminalExternalLinkProvider): IDisposable {
registerExternalLinkProvider(provideLinks: OmitFirstArg<ITerminalExternalLinkProvider['provideLinks']>): IDisposable {
// Clear and re-register the standard link providers so they are a lower priority than the new one
this._clearLinkProviders();
const wrappedLinkProvider = this._instantiationService.createInstance(TerminalExternalLinkProviderAdapter, this._xterm, instance, linkProvider, this._wrapLinkHandler.bind(this), this._tooltipCallback.bind(this));
const detectorId = `extension-${this._externalLinkProviders.length}`;
const wrappedLinkProvider = this._setupLinkDetector(detectorId, new TerminalExternalLinkDetector(detectorId, this._xterm, provideLinks), true);
const newLinkProvider = this._xterm.registerLinkProvider(wrappedLinkProvider);
this._linkProvidersDisposables.push(newLinkProvider);
this._externalLinkProviders.push(newLinkProvider);
this._registerStandardLinkProviders();
return newLinkProvider;
}
protected _wrapLinkHandler(handler: (event: MouseEvent | undefined, link: string) => void): XtermLinkMatcherHandler {
return async (event: MouseEvent | undefined, link: string) => {
// Prevent default electron link handling so Alt+Click mode works normally
event?.preventDefault();
// Require correct modifier on click unless event is coming from linkQuickPick selection
if (event && !(event instanceof TerminalLinkQuickPickEvent) && !this._isLinkActivationModifierDown(event)) {
return;
}
// Just call the handler if there is no before listener
handler(event, link);
};
}
protected get _localLinkRegex(): RegExp {
if (!this._processManager) {
throw new Error('Process manager is required');
@ -267,45 +262,6 @@ export class TerminalLinkManager extends DisposableStore {
return new RegExp(`${baseLocalLinkClause}(${lineAndColumnClause})`);
}
private async _handleLocalLink(link: string): Promise<void> {
// TODO: This gets resolved again but doesn't need to as it's already validated
const resolvedLink = await this._resolvePath(link);
if (!resolvedLink) {
return;
}
const lineColumnInfo: LineColumnInfo = this.extractLineColumnInfo(link);
const selection: ITextEditorSelection = {
startLineNumber: lineColumnInfo.lineNumber,
startColumn: lineColumnInfo.columnNumber
};
await this._editorService.openEditor({
resource: resolvedLink.uri,
options: { pinned: true, selection, revealIfOpened: true }
});
}
private _handleHypertextLink(url: string): void {
this._openerService.open(url, {
allowTunneling: !!(this._processManager && this._processManager.remoteAuthority),
allowContributedOpeners: true,
});
}
private async _handleProtocolLink(link: string): Promise<void> {
// Check if it's a file:/// link, hand off to local link handler so to open an editor and
// respect line/col attachment
const uri = URI.parse(link);
if (uri.scheme === Schemas.file) {
// Just using fsPath here is unsafe: https://github.com/microsoft/vscode/issues/109076
const fsPath = uri.fsPath;
this._handleLocalLink(((this._osPath.sep === posix.sep) && this._processManager.os === OperatingSystem.Windows) ? fsPath.replace(/\\/g, posix.sep) : fsPath);
return;
}
// Open as a web link if it's not a file
this._handleHypertextLink(link);
}
protected _isLinkActivationModifierDown(event: MouseEvent): boolean {
const editorConf = this._configurationService.getValue<{ multiCursorModifier: 'ctrlCmd' | 'alt' }>('editor');
if (editorConf.multiCursorModifier === 'ctrlCmd') {
@ -408,11 +364,22 @@ export class TerminalLinkManager extends DisposableStore {
return link;
}
private async _resolvePath(link: string): Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined> {
private async _resolvePath(link: string, uri?: URI): Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined> {
if (!this._processManager) {
throw new Error('Process manager is required');
}
if (uri) {
try {
const stat = await this._fileService.resolve(uri);
return { uri, link, isDirectory: stat.isDirectory };
}
catch (e) {
// Does not exist
return undefined;
}
}
const preprocessedLink = this._preprocessPath(link);
if (!preprocessedLink) {
return undefined;
@ -449,40 +416,6 @@ export class TerminalLinkManager extends DisposableStore {
}
}
/**
* Returns line and column number of URl if that is present.
*
* @param link Url link which may contain line and column number.
*/
extractLineColumnInfo(link: string): LineColumnInfo {
const matches: string[] | null = this._localLinkRegex.exec(link);
const lineColumnInfo: LineColumnInfo = {
lineNumber: 1,
columnNumber: 1
};
if (!matches || !this._processManager) {
return lineColumnInfo;
}
const lineAndColumnMatchIndex = this._processManager.os === OperatingSystem.Windows ? winLineAndColumnMatchIndex : unixLineAndColumnMatchIndex;
for (let i = 0; i < lineAndColumnClause.length; i++) {
const lineMatchIndex = lineAndColumnMatchIndex + (lineAndColumnClauseGroupCount * i);
const rowNumber = matches[lineMatchIndex];
if (rowNumber) {
lineColumnInfo['lineNumber'] = parseInt(rowNumber, 10);
// Check if column number exists
const columnNumber = matches[lineMatchIndex + 2];
if (columnNumber) {
lineColumnInfo['columnNumber'] = parseInt(columnNumber, 10);
}
break;
}
}
return lineColumnInfo;
}
/**
* Returns url from link as link may contain line and column information.
*
@ -497,7 +430,7 @@ export class TerminalLinkManager extends DisposableStore {
}
}
export interface LineColumnInfo {
export interface ILineColumnInfo {
lineNumber: number;
columnNumber: number;
}

View file

@ -0,0 +1,240 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Schemas } from 'vs/base/common/network';
import { IPath, posix, win32 } from 'vs/base/common/path';
import { OperatingSystem } from 'vs/base/common/platform';
import { URI } from 'vs/base/common/uri';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { ITextEditorSelection } from 'vs/platform/editor/common/editor';
import { IFileService } from 'vs/platform/files/common/files';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { ITerminalLinkOpener, ITerminalSimpleLink } from 'vs/workbench/contrib/terminal/browser/links/links';
import { ILineColumnInfo } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager';
import { getLocalLinkRegex, lineAndColumnClause, lineAndColumnClauseGroupCount, unixLineAndColumnMatchIndex, winLineAndColumnMatchIndex } from 'vs/workbench/contrib/terminal/browser/links/terminalLocalLinkDetector';
import { ITerminalCapabilityStore, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
import { IHostService } from 'vs/workbench/services/host/browser/host';
import { QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder';
import { ISearchService } from 'vs/workbench/services/search/common/search';
export class TerminalLocalFileLinkOpener implements ITerminalLinkOpener {
constructor(
private readonly _os: OperatingSystem,
@IEditorService private readonly _editorService: IEditorService,
) {
}
async open(link: ITerminalSimpleLink): Promise<void> {
if (!link.uri) {
throw new Error('Tried to open file link without a resolved URI');
}
const lineColumnInfo: ILineColumnInfo = this.extractLineColumnInfo(link.text);
const selection: ITextEditorSelection = {
startLineNumber: lineColumnInfo.lineNumber,
startColumn: lineColumnInfo.columnNumber
};
await this._editorService.openEditor({
resource: link.uri,
options: { pinned: true, selection, revealIfOpened: true }
});
}
/**
* Returns line and column number of URl if that is present, otherwise line 1 column 1.
*
* @param link Url link which may contain line and column number.
*/
extractLineColumnInfo(link: string): ILineColumnInfo {
const matches: string[] | null = getLocalLinkRegex(this._os).exec(link);
const lineColumnInfo: ILineColumnInfo = {
lineNumber: 1,
columnNumber: 1
};
if (!matches) {
return lineColumnInfo;
}
const lineAndColumnMatchIndex = this._os === OperatingSystem.Windows ? winLineAndColumnMatchIndex : unixLineAndColumnMatchIndex;
for (let i = 0; i < lineAndColumnClause.length; i++) {
const lineMatchIndex = lineAndColumnMatchIndex + (lineAndColumnClauseGroupCount * i);
const rowNumber = matches[lineMatchIndex];
if (rowNumber) {
lineColumnInfo['lineNumber'] = parseInt(rowNumber, 10);
// Check if column number exists
const columnNumber = matches[lineMatchIndex + 2];
if (columnNumber) {
lineColumnInfo['columnNumber'] = parseInt(columnNumber, 10);
}
break;
}
}
return lineColumnInfo;
}
}
export class TerminalLocalFolderInWorkspaceLinkOpener implements ITerminalLinkOpener {
constructor(@ICommandService private readonly _commandService: ICommandService) {
}
async open(link: ITerminalSimpleLink): Promise<void> {
if (!link.uri) {
throw new Error('Tried to open folder in workspace link without a resolved URI');
}
await this._commandService.executeCommand('revealInExplorer', link.uri);
}
}
export class TerminalLocalFolderOutsideWorkspaceLinkOpener implements ITerminalLinkOpener {
constructor(@IHostService private readonly _hostService: IHostService) {
}
async open(link: ITerminalSimpleLink): Promise<void> {
if (!link.uri) {
throw new Error('Tried to open folder in workspace link without a resolved URI');
}
this._hostService.openWindow([{ folderUri: link.uri }], { forceNewWindow: true });
}
}
export class TerminalSearchLinkOpener implements ITerminalLinkOpener {
private readonly _fileQueryBuilder = this._instantiationService.createInstance(QueryBuilder);
constructor(
private readonly _capabilities: ITerminalCapabilityStore,
private readonly _localFileOpener: TerminalLocalFileLinkOpener,
private readonly _os: OperatingSystem,
@IFileService private readonly _fileService: IFileService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IQuickInputService private readonly _quickInputService: IQuickInputService,
@ISearchService private readonly _searchService: ISearchService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
@IWorkbenchEnvironmentService private readonly _workbenchEnvironmentService: IWorkbenchEnvironmentService,
) {
}
async open(link: ITerminalSimpleLink): Promise<void> {
const pathSeparator = osPathModule(this._os).sep;
// Remove file:/// and any leading ./ or ../ since quick access doesn't understand that format
let text = link.text.replace(/^file:\/\/\/?/, '');
text = osPathModule(this._os).normalize(text).replace(/^(\.+[\\/])+/, '');
// Remove `:in` from the end which is how Ruby outputs stack traces
text = text.replace(/:in$/, '');
// If any of the names of the folders in the workspace matches
// a prefix of the link, remove that prefix and continue
this._workspaceContextService.getWorkspace().folders.forEach((folder) => {
if (text.substring(0, folder.name.length + 1) === folder.name + pathSeparator) {
text = text.substring(folder.name.length + 1);
return;
}
});
let matchLink = text;
if (this._capabilities.has(TerminalCapability.CommandDetection)) {
matchLink = this._updateLinkWithRelativeCwd(link.bufferRange.start.y, text, pathSeparator) || text;
}
const sanitizedLink = matchLink.replace(/:\d+(:\d+)?$/, '');
try {
const uri = await this._getExactMatch(sanitizedLink);
if (uri) {
return this._localFileOpener.open({
text: matchLink,
uri,
bufferRange: link.bufferRange,
type: link.type
});
}
} catch {
// Fallback to searching quick access
return this._quickInputService.quickAccess.show(text);
}
// Fallback to searching quick access
return this._quickInputService.quickAccess.show(text);
}
/*
* For shells with the CwdDetection capability, the cwd relative to the line
* of the particular link is used to narrow down the result for an exact file match, if possible.
*/
private _updateLinkWithRelativeCwd(y: number, text: string, pathSeparator: string): string | undefined {
const cwd = this._capabilities.get(TerminalCapability.CommandDetection)?.getCwdForLine(y);
if (!cwd) {
return undefined;
}
if (!text.includes(pathSeparator)) {
text = cwd + pathSeparator + text;
} else {
let commonDirs = 0;
let i = 0;
const cwdPath = cwd.split(pathSeparator).reverse();
const linkPath = text.split(pathSeparator);
while (i < cwdPath.length) {
if (cwdPath[i] === linkPath[i]) {
commonDirs++;
}
i++;
}
text = cwd + pathSeparator + linkPath.slice(commonDirs).join(pathSeparator);
}
return text;
}
private async _getExactMatch(sanitizedLink: string): Promise<URI | undefined> {
let exactResource: URI | undefined;
if (osPathModule(this._os).isAbsolute(sanitizedLink)) {
const scheme = this._workbenchEnvironmentService.remoteAuthority ? Schemas.vscodeRemote : Schemas.file;
const resource = URI.from({ scheme, path: sanitizedLink });
try {
const fileStat = await this._fileService.resolve(resource);
if (fileStat.isFile) {
exactResource = resource;
}
} catch {
// File doesn't exist, continue on
}
}
if (!exactResource) {
const results = await this._searchService.fileSearch(
this._fileQueryBuilder.file(this._workspaceContextService.getWorkspace().folders, {
// Remove optional :row:col from the link as openEditor supports it
filePattern: sanitizedLink,
maxResults: 2
})
);
if (results.results.length === 1) {
exactResource = results.results[0].resource;
}
}
return exactResource;
}
}
export class TerminalUrlLinkOpener implements ITerminalLinkOpener {
constructor(
private readonly _isRemote: boolean,
@IOpenerService private readonly _openerService: IOpenerService,
) {
}
async open(link: ITerminalSimpleLink): Promise<void> {
if (!link.uri) {
throw new Error('Tried to open a url without a resolved URI');
}
this._openerService.open(link.uri || URI.parse(link.text), {
allowTunneling: this._isRemote,
allowContributedOpeners: true,
});
}
}
function osPathModule(os: OperatingSystem): IPath {
return os === OperatingSystem.Windows ? win32 : posix;
}

View file

@ -0,0 +1,226 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { OperatingSystem } from 'vs/base/common/platform';
import { withUndefinedAsNull } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { ITerminalLinkDetector, ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { convertLinkRangeToBuffer, getXtermLineContent } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { IBufferLine, Terminal } from 'xterm';
const enum Constants {
/**
* The max line length to try extract word links from.
*/
MaxLineLength = 2000,
/**
* The maximum number of links in a line to resolve against the file system. This limit is put
* in place to avoid sending excessive data when remote connections are in place.
*/
MaxResolvedLinksInLine = 10,
/**
* The maximum length of a link to resolve against the file system. This limit is put in place
* to avoid sending excessive data when remote connections are in place.
*/
MaxResolvedLinkLength = 1024,
}
const pathPrefix = '(\\.\\.?|\\~)';
const pathSeparatorClause = '\\/';
// '":; are allowed in paths but they are often separators so ignore them
// Also disallow \\ to prevent a catastropic backtracking case #24795
const excludedPathCharactersClause = '[^\\0\\s!`&*()\\[\\]\'":;\\\\]';
/** A regex that matches paths in the form /foo, ~/foo, ./foo, ../foo, foo/bar */
export const unixLocalLinkClause = '((' + pathPrefix + '|(' + excludedPathCharactersClause + ')+)?(' + pathSeparatorClause + '(' + excludedPathCharactersClause + ')+)+)';
export const winDrivePrefix = '(?:\\\\\\\\\\?\\\\)?[a-zA-Z]:';
const winPathPrefix = '(' + winDrivePrefix + '|\\.\\.?|\\~)';
const winPathSeparatorClause = '(\\\\|\\/)';
const winExcludedPathCharactersClause = '[^\\0<>\\?\\|\\/\\s!`&*()\\[\\]\'":;]';
/** A regex that matches paths in the form \\?\c:\foo c:\foo, ~\foo, .\foo, ..\foo, foo\bar */
export 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. */
export 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'} ]`);
// Changing any regex may effect this value, hence changes this as well if required.
export const winLineAndColumnMatchIndex = 12;
export const unixLineAndColumnMatchIndex = 11;
// Each line and column clause have 6 groups (ie no. of expressions in round brackets)
export const lineAndColumnClauseGroupCount = 6;
const cachedValidatedLinks = new Map<string, { uri: URI; link: string; isDirectory: boolean } | null>();
export class TerminalLocalLinkDetector implements ITerminalLinkDetector {
static id = 'local';
private _cacheTilTimeout = 0;
protected _enableCaching = true;
constructor(
readonly xterm: Terminal,
private readonly _os: OperatingSystem,
private readonly _resolvePath: (link: string) => Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined>,
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService
) {
}
async detect(lines: IBufferLine[], startLine: number, endLine: number): Promise<ITerminalSimpleLink[]> {
const links: ITerminalSimpleLink[] = [];
// Reset cached link TTL
if (this._enableCaching) {
if (this._cacheTilTimeout) {
window.clearTimeout(this._cacheTilTimeout);
}
this._cacheTilTimeout = window.setTimeout(() => cachedValidatedLinks.clear(), 10000);
}
// Get the text representation of the wrapped line
const text = getXtermLineContent(this.xterm.buffer.active, startLine, endLine, this.xterm.cols);
if (text === '' || text.length > Constants.MaxLineLength) {
return [];
}
// clone regex to do a global search on text
const rex = new RegExp(getLocalLinkRegex(this._os), 'g');
let match;
let stringIndex = -1;
let resolvedLinkCount = 0;
while ((match = rex.exec(text)) !== null) {
// const link = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
let link = match[0];
if (!link) {
// 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(link, stringIndex + 1);
rex.lastIndex = stringIndex + link.length;
if (stringIndex < 0) {
// invalid stringIndex (should not have happened)
break;
}
// Adjust the link range to exclude a/ and b/ if it looks like a git diff
if (
// --- a/foo/bar
// +++ b/foo/bar
((text.startsWith('--- a/') || text.startsWith('+++ b/')) && stringIndex === 4) ||
// diff --git a/foo/bar b/foo/bar
(text.startsWith('diff --git') && (link.startsWith('a/') || link.startsWith('b/')))
) {
link = link.substring(2);
stringIndex += 2;
}
// 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 + link.length + 1,
endLineNumber: 1
}, startLine);
// Don't try resolve any links of excessive length
if (link.length > Constants.MaxResolvedLinkLength) {
continue;
}
let linkStat = cachedValidatedLinks.get(link);
// The link is cached as doesn't exist
if (linkStat === null) {
continue;
}
// The link isn't cached
if (linkStat === undefined) {
const linkCandidates = [link];
if (link.match(/^(\.\.[\/\\])+/)) {
linkCandidates.push(link.replace(/^(\.\.[\/\\])+/, ''));
}
linkStat = await this._validateLinkCandidates(linkCandidates);
if (this._enableCaching) {
cachedValidatedLinks.set(link, withUndefinedAsNull(linkStat));
}
}
// Create the link if validated
if (linkStat) {
let type: TerminalBuiltinLinkType;
if (linkStat.isDirectory) {
if (this._isDirectoryInsideWorkspace(linkStat.uri)) {
type = TerminalBuiltinLinkType.LocalFolderInWorkspace;
} else {
type = TerminalBuiltinLinkType.LocalFolderOutsideWorkspace;
}
} else {
type = TerminalBuiltinLinkType.LocalFile;
}
links.push({
text: linkStat.link,
uri: linkStat.uri,
bufferRange,
type
});
// Stop early if too many links exist in the line
if (++resolvedLinkCount >= Constants.MaxResolvedLinksInLine) {
break;
}
}
}
return links;
}
private _isDirectoryInsideWorkspace(uri: URI) {
const folders = this._workspaceContextService.getWorkspace().folders;
for (let i = 0; i < folders.length; i++) {
if (this._uriIdentityService.extUri.isEqualOrParent(uri, folders[i].uri)) {
return true;
}
}
return false;
}
private async _validateLinkCandidates(linkCandidates: string[]): Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined> {
for (const link of linkCandidates) {
const result = await this._resolvePath(link);
if (result) {
return result;
}
}
return undefined;
}
}
export function getLocalLinkRegex(os: OperatingSystem): RegExp {
const baseLocalLinkClause = os === OperatingSystem.Windows ? winLocalLinkClause : unixLocalLinkClause;
// Append line and column number regex
return new RegExp(`${baseLocalLinkClause}(${lineAndColumnClause})`);
}

View file

@ -0,0 +1,165 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Schemas } from 'vs/base/common/network';
import { withUndefinedAsNull } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { ILinkComputerTarget, LinkComputer } from 'vs/editor/common/languages/linkComputer';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { ITerminalLinkDetector, ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { convertLinkRangeToBuffer, getXtermLineContent } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { IBufferLine, Terminal } from 'xterm';
const enum Constants {
/**
* The maximum number of links in a line to resolve against the file system. This limit is put
* in place to avoid sending excessive data when remote connections are in place.
*/
MaxResolvedLinksInLine = 10,
/**
* The maximum length of a link to resolve against the file system. This limit is put in place
* to avoid sending excessive data when remote connections are in place.
*/
MaxResolvedLinkLength = 1024,
}
const cachedValidatedLinks = new Map<string, { uri: URI; link: string; isDirectory: boolean } | null>();
export class TerminalUriLinkDetector implements ITerminalLinkDetector {
static id = 'uri';
private _cacheTilTimeout = 0;
protected _enableCaching = true;
constructor(
readonly xterm: Terminal,
private readonly _resolvePath: (link: string, uri?: URI) => Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined>,
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService
) {
}
async detect(lines: IBufferLine[], startLine: number, endLine: number): Promise<ITerminalSimpleLink[]> {
const links: ITerminalSimpleLink[] = [];
// Reset cached link TTL
if (this._enableCaching) {
if (this._cacheTilTimeout) {
window.clearTimeout(this._cacheTilTimeout);
}
this._cacheTilTimeout = window.setTimeout(() => cachedValidatedLinks.clear(), 10000);
}
const linkComputerTarget = new TerminalLinkAdapter(this.xterm, startLine, endLine);
const computedLinks = LinkComputer.computeLinks(linkComputerTarget);
let resolvedLinkCount = 0;
for (const computedLink of computedLinks) {
const bufferRange = convertLinkRangeToBuffer(lines, this.xterm.cols, computedLink.range, startLine);
// Check if the link is within the mouse position
const uri = computedLink.url
? (typeof computedLink.url === 'string' ? URI.parse(computedLink.url) : computedLink.url)
: undefined;
if (!uri) {
continue;
}
const text = computedLink.url?.toString() || '';
// Handle non-file scheme links
if (uri.scheme !== Schemas.file) {
links.push({
text,
uri,
bufferRange,
type: TerminalBuiltinLinkType.Url
});
continue;
}
// Don't try resolve any links of excessive length
if (text.length > Constants.MaxResolvedLinkLength) {
continue;
}
// Filter out URI with unrecognized authorities
if (uri.authority.length !== 2 && uri.authority.endsWith(':')) {
continue;
}
let linkStat = cachedValidatedLinks.get(text);
// The link is cached as doesn't exist
if (linkStat === null) {
continue;
}
// The link isn't cached
if (linkStat === undefined) {
linkStat = await this._resolvePath(text, uri);
if (this._enableCaching) {
cachedValidatedLinks.set(text, withUndefinedAsNull(linkStat));
}
}
// Create the link if validated
if (linkStat) {
let type: TerminalBuiltinLinkType;
if (linkStat.isDirectory) {
if (this._isDirectoryInsideWorkspace(linkStat.uri)) {
type = TerminalBuiltinLinkType.LocalFolderInWorkspace;
} else {
type = TerminalBuiltinLinkType.LocalFolderOutsideWorkspace;
}
} else {
type = TerminalBuiltinLinkType.LocalFile;
}
links.push({
text: linkStat.link,
uri: linkStat.uri,
bufferRange,
type
});
// Stop early if too many links exist in the line
if (++resolvedLinkCount >= Constants.MaxResolvedLinksInLine) {
break;
}
}
}
return links;
}
private _isDirectoryInsideWorkspace(uri: URI) {
const folders = this._workspaceContextService.getWorkspace().folders;
for (let i = 0; i < folders.length; i++) {
if (this._uriIdentityService.extUri.isEqualOrParent(uri, folders[i].uri)) {
return true;
}
}
return false;
}
}
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, this._xterm.cols);
}
}

View file

@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ITerminalSimpleLink, ITerminalLinkDetector, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { convertLinkRangeToBuffer, getXtermLineContent } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { ITerminalConfiguration, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal';
import { IBufferLine, Terminal } from 'xterm';
const enum Constants {
/**
* The max line length to try extract word links from.
*/
MaxLineLength = 2000
}
interface Word {
startIndex: number;
endIndex: number;
text: string;
}
export class TerminalWordLinkDetector implements ITerminalLinkDetector {
static id = 'word';
constructor(
readonly xterm: Terminal,
@IConfigurationService private readonly _configurationService: IConfigurationService,
) {
}
detect(lines: IBufferLine[], startLine: number, endLine: number): ITerminalSimpleLink[] {
const links: ITerminalSimpleLink[] = [];
const wordSeparators = this._configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION).wordSeparators;
// Get the text representation of the wrapped line
const text = getXtermLineContent(this.xterm.buffer.active, startLine, endLine, this.xterm.cols);
if (text === '' || text.length > Constants.MaxLineLength) {
return [];
}
// Parse out all words from the wrapped line
const words: Word[] = this._parseWords(text, wordSeparators);
// Map the words to ITerminalLink objects
for (const word of words) {
if (word.text === '') {
continue;
}
if (word.text.length > 0 && word.text.charAt(word.text.length - 1) === ':') {
word.text = word.text.slice(0, -1);
word.endIndex--;
}
const bufferRange = convertLinkRangeToBuffer(
lines,
this.xterm.cols,
{
startColumn: word.startIndex + 1,
startLineNumber: 1,
endColumn: word.endIndex + 1,
endLineNumber: 1
},
startLine
);
links.push({
text: word.text,
bufferRange,
type: TerminalBuiltinLinkType.Search
});
}
return links;
}
private _parseWords(text: string, separators: string): Word[] {
const words: Word[] = [];
const wordSeparators: string[] = separators.split('');
const characters = text.split('');
let startIndex = 0;
for (let i = 0; i < text.length; i++) {
if (wordSeparators.includes(characters[i])) {
words.push({ startIndex, endIndex: i, text: text.substring(startIndex, i) });
startIndex = i + 1;
}
}
if (startIndex < text.length) {
words.push({ startIndex, endIndex: text.length, text: text.substring(startIndex) });
}
return words;
}
}

View file

@ -670,7 +670,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
if (this._processManager.os === OperatingSystem.Windows) {
xterm.raw.options.windowsMode = processTraits.requiresWindowsMode || false;
}
this._linkManager = this._instantiationService.createInstance(TerminalLinkManager, xterm, this._processManager!, this.capabilities);
this._linkManager = this._instantiationService.createInstance(TerminalLinkManager, xterm.raw, this._processManager!, this.capabilities);
this._areLinksReady = true;
this._onLinksReady.fire(this);
});
@ -2042,7 +2042,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
if (!this._linkManager) {
throw new Error('TerminalInstance.registerLinkProvider before link manager was ready');
}
return this._linkManager.registerExternalLinkProvider(this, provider);
// Avoid a circular dependency by binding the terminal instances to the external link provider
return this._linkManager.registerExternalLinkProvider(provider.provideLinks.bind(provider, this));
}
async rename(title?: string | 'triggerQuickpick') {

View file

@ -0,0 +1,55 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { deepStrictEqual } from 'assert';
import { ITerminalLinkDetector, ITerminalSimpleLink, TerminalLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { URI } from 'vs/base/common/uri';
import { IBufferLine } from 'xterm';
import { Schemas } from 'vs/base/common/network';
export async function assertLinkHelper(
text: string,
expected: (Pick<ITerminalSimpleLink, 'text'> & { range: [number, number][] })[],
detector: ITerminalLinkDetector,
expectedType: TerminalLinkType
) {
detector.xterm.reset();
// Write the text and wait for the parser to finish
await new Promise<void>(r => detector.xterm.write(text, r));
// Ensure all links are provided
const lines: IBufferLine[] = [];
for (let i = 0; i < detector.xterm.buffer.active.cursorY + 1; i++) {
lines.push(detector.xterm.buffer.active.getLine(i)!);
}
const actualLinks = (await detector.detect(lines, 0, detector.xterm.buffer.active.cursorY)).map(e => {
return {
text: e.text,
type: expectedType,
bufferRange: e.bufferRange
};
});
const expectedLinks = expected.map(e => {
return {
type: expectedType,
text: e.text,
bufferRange: {
start: { x: e.range[0][0], y: e.range[0][1] },
end: { x: e.range[1][0], y: e.range[1][1] },
}
};
});
deepStrictEqual(actualLinks, expectedLinks);
}
export async function resolveLinkForTest(link: string, uri?: URI): Promise<{ uri: URI; link: string; isDirectory: boolean } | undefined> {
return {
link,
uri: URI.from({ scheme: Schemas.file, path: link }),
isDirectory: false,
};
}

View file

@ -3,27 +3,23 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ILink, Terminal } from 'xterm';
import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal';
import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ITerminalConfigHelper, ITerminalConfiguration, ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal';
import { deepStrictEqual, strictEqual } from 'assert';
import { equals } from 'vs/base/common/arrays';
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { IStorageService } from 'vs/platform/storage/common/storage';
import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
import { TerminalLocation } from 'vs/platform/terminal/common/terminal';
import { TestViewDescriptorService } from 'vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import { IDetectedLinks, TerminalLinkManager } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager';
import { equals } from 'vs/base/common/arrays';
import { ITerminalCapabilityImplMap, ITerminalCapabilityStore, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { TerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
import { ITerminalConfiguration, ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal';
import { TestViewDescriptorService } from 'vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test';
import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
import { ILink, Terminal } from 'xterm';
const defaultTerminalConfig: Partial<ITerminalConfiguration> = {
fontFamily: 'monospace',
@ -38,7 +34,7 @@ const defaultTerminalConfig: Partial<ITerminalConfiguration> = {
class TestLinkManager extends TerminalLinkManager {
private _links: IDetectedLinks | undefined;
override async getLinksForType(y: number, type: 'word' | 'web' | 'file'): Promise<ILink[] | undefined> {
protected override async _getLinksForType(y: number, type: 'word' | 'web' | 'file'): Promise<ILink[] | undefined> {
switch (type) {
case 'word':
return this._links?.wordLinks?.[y] ? [this._links?.wordLinks?.[y]] : undefined;
@ -58,8 +54,7 @@ suite('TerminalLinkManager', () => {
let configurationService: TestConfigurationService;
let themeService: TestThemeService;
let viewDescriptorService: TestViewDescriptorService;
let xterm: XtermTerminal;
let configHelper: ITerminalConfigHelper;
let xterm: Terminal;
let linkManager: TestLinkManager;
setup(() => {
@ -82,8 +77,7 @@ suite('TerminalLinkManager', () => {
instantiationService.stub(IThemeService, themeService);
instantiationService.stub(IViewDescriptorService, viewDescriptorService);
configHelper = instantiationService.createInstance(TerminalConfigHelper);
xterm = instantiationService.createInstance(XtermTerminal, Terminal, configHelper, 80, 30, TerminalLocation.Panel, new TerminalCapabilityStore());
xterm = new Terminal({ cols: 80, rows: 30 });
linkManager = instantiationService.createInstance(TestLinkManager, xterm, upcastPartial<ITerminalProcessManager>({}), {
get<T extends TerminalCapability>(capability: T): ITerminalCapabilityImplMap[T] | undefined {
return undefined;

View file

@ -0,0 +1,197 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { deepStrictEqual } from 'assert';
import { Schemas } from 'vs/base/common/network';
import { OperatingSystem } from 'vs/base/common/platform';
import { URI } from 'vs/base/common/uri';
import { ITextResourceEditorInput } from 'vs/platform/editor/common/editor';
import { IFileService, IFileStat, IFileStatWithMetadata, IResolveFileOptions, IResolveMetadataFileOptions } from 'vs/platform/files/common/files';
import { FileService } from 'vs/platform/files/common/fileService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { CommandDetectionCapability } from 'vs/workbench/contrib/terminal/browser/capabilities/commandDetectionCapability';
import { TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalLocalFileLinkOpener, TerminalSearchLinkOpener } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkOpeners';
import { TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { TerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
import { ITerminalCommand, IXtermMarker } from 'vs/workbench/contrib/terminal/common/terminal';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
import { TestContextService } from 'vs/workbench/test/common/workbenchTestServices';
import { Terminal } from 'xterm';
export interface ITerminalLinkActivationResult {
source: 'editor' | 'search';
link: string;
}
class TestCommandDetectionCapability extends CommandDetectionCapability {
setCommands(commands: ITerminalCommand[]) {
this._commands = commands;
}
}
class TestFileService extends FileService {
private _files: URI[] | '*' = '*';
override async resolve(resource: URI, options: IResolveMetadataFileOptions): Promise<IFileStatWithMetadata>;
override async resolve(resource: URI, options?: IResolveFileOptions): Promise<IFileStat>;
override async resolve(resource: URI, options?: IResolveFileOptions): Promise<IFileStat> {
if (this._files === '*' || this._files.some(e => e.toString() === resource.toString())) {
return { isFile: true, isDirectory: false, isSymbolicLink: false } as IFileStat;
} else {
return { isFile: false, isDirectory: false, isSymbolicLink: false } as IFileStat;
}
}
setFiles(files: URI[] | '*'): void {
this._files = files;
}
}
suite('Workbench - TerminalLinkOpeners', () => {
let instantiationService: TestInstantiationService;
let fileService: TestFileService;
let activationResult: ITerminalLinkActivationResult | undefined;
let xterm: Terminal;
setup(() => {
instantiationService = new TestInstantiationService();
fileService = new TestFileService(new NullLogService());
instantiationService.set(IFileService, fileService);
instantiationService.set(ILogService, new NullLogService());
instantiationService.set(IWorkspaceContextService, new TestContextService());
instantiationService.stub(IWorkbenchEnvironmentService, {
remoteAuthority: undefined
} as Partial<IWorkbenchEnvironmentService>);
// Allow intercepting link activations
activationResult = undefined;
instantiationService.stub(IQuickInputService, {
quickAccess: {
show(link: string) {
activationResult = { link, source: 'search' };
}
}
} as Partial<IQuickInputService>);
instantiationService.stub(IEditorService, {
async openEditor(editor: ITextResourceEditorInput): Promise<any> {
activationResult = {
source: 'editor',
link: editor.resource?.toString()
};
}
} as Partial<IEditorService>);
// /*editorServiceSpy = */instantiationService.spy(IEditorService, 'openEditor');
xterm = new Terminal();
});
suite('TerminalSearchLinkOpener', () => {
let opener: TerminalSearchLinkOpener;
let capabilities: TerminalCapabilityStore;
let commandDetection: TestCommandDetectionCapability;
let localFileOpener: TerminalLocalFileLinkOpener;
setup(() => {
capabilities = new TerminalCapabilityStore();
commandDetection = instantiationService.createInstance(TestCommandDetectionCapability, xterm);
capabilities.add(TerminalCapability.CommandDetection, commandDetection);
});
suite('macOS/Linux', () => {
setup(() => {
localFileOpener = instantiationService.createInstance(TerminalLocalFileLinkOpener, OperatingSystem.Linux);
opener = instantiationService.createInstance(TerminalSearchLinkOpener, capabilities, localFileOpener, OperatingSystem.Linux);
});
test('should apply the cwd to the link only when the file exists and cwdDetection is enabled', async () => {
const cwd = '/Users/home/folder';
const absoluteFile = '/Users/home/folder/file.txt';
fileService.setFiles([
URI.from({ scheme: Schemas.file, path: absoluteFile })
]);
// Set a fake detected command starting as line 0 to establish the cwd
commandDetection.setCommands([{
command: '',
cwd,
timestamp: 0,
getOutput() { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any
}]);
await opener.open({
text: 'file.txt',
bufferRange: { start: { x: 1, y: 1 }, end: { x: 8, y: 1 } },
type: TerminalBuiltinLinkType.Search
});
deepStrictEqual(activationResult, {
link: 'file:///Users/home/folder/file.txt',
source: 'editor'
});
// Clear deteceted commands and ensure the same request results in a search
commandDetection.setCommands([]);
await opener.open({
text: 'file.txt',
bufferRange: { start: { x: 1, y: 1 }, end: { x: 8, y: 1 } },
type: TerminalBuiltinLinkType.Search
});
deepStrictEqual(activationResult, {
link: 'file.txt',
source: 'search'
});
});
});
suite('Windows', () => {
setup(() => {
localFileOpener = instantiationService.createInstance(TerminalLocalFileLinkOpener, OperatingSystem.Windows);
opener = instantiationService.createInstance(TerminalSearchLinkOpener, capabilities, localFileOpener, OperatingSystem.Windows);
});
test('should apply the cwd to the link only when the file exists and cwdDetection is enabled', async () => {
const cwd = 'c:\\Users\\home\\folder';
const absoluteFile = 'c:\\Users\\home\\folder\\file.txt';
fileService.setFiles([
URI.from({ scheme: Schemas.file, path: absoluteFile })
]);
// Set a fake detected command starting as line 0 to establish the cwd
commandDetection.setCommands([{
command: '',
cwd,
timestamp: 0,
getOutput() { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any
}]);
await opener.open({
text: 'file.txt',
bufferRange: { start: { x: 1, y: 1 }, end: { x: 8, y: 1 } },
type: TerminalBuiltinLinkType.Search
});
deepStrictEqual(activationResult, {
link: 'file:///c%3A%5CUsers%5Chome%5Cfolder%5Cfile.txt',
source: 'editor'
});
// Clear deteceted commands and ensure the same request results in a search
commandDetection.setCommands([]);
await opener.open({
text: 'file.txt',
bufferRange: { start: { x: 1, y: 1 }, end: { x: 8, y: 1 } },
type: TerminalBuiltinLinkType.Search
});
deepStrictEqual(activationResult, {
link: 'file.txt',
source: 'search'
});
});
});
});
});

View file

@ -0,0 +1,172 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { OperatingSystem } from 'vs/base/common/platform';
import { format } from 'vs/base/common/strings';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalLocalLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalLocalLinkDetector';
import { assertLinkHelper, resolveLinkForTest } from 'vs/workbench/contrib/terminal/test/browser/links/linkTestUtils';
import { Terminal } from 'xterm';
const unixLinks = [
'/foo',
'~/foo',
'./foo',
'./$foo',
'../foo',
'/foo/bar',
'/foo/bar+more',
'foo/bar',
'foo/bar+more',
];
const windowsLinks = [
'c:\\foo',
'\\\\?\\c:\\foo',
'c:/foo',
'.\\foo',
'./foo',
'./$foo',
'..\\foo',
'~\\foo',
'~/foo',
'c:/foo/bar',
'c:\\foo\\bar',
'c:\\foo\\bar+more',
'c:\\foo/bar\\baz',
'foo/bar',
'foo/bar',
'foo\\bar',
'foo\\bar+more',
];
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' },
{ urlFormat: '{0}\',{1}', line: '5' }
];
suite('Workbench - TerminalLocalLinkDetector', () => {
let instantiationService: TestInstantiationService;
let configurationService: TestConfigurationService;
let detector: TerminalLocalLinkDetector;
let xterm: Terminal;
async function assertLink(
type: TerminalBuiltinLinkType,
text: string,
expected: (Pick<ITerminalSimpleLink, 'text'> & { range: [number, number][] })[]
) {
await assertLinkHelper(text, expected, detector, type);
}
setup(() => {
instantiationService = new TestInstantiationService();
configurationService = new TestConfigurationService();
instantiationService.stub(IConfigurationService, configurationService);
xterm = new Terminal({ cols: 80, rows: 30 });
});
suite('platform independent', () => {
setup(() => {
detector = instantiationService.createInstance(TerminalLocalLinkDetector, xterm, OperatingSystem.Linux, resolveLinkForTest);
});
test('should support multiple link results', async () => {
await assertLink(TerminalBuiltinLinkType.LocalFile, './foo ./bar', [
{ range: [[1, 1], [5, 1]], text: './foo' },
{ range: [[7, 1], [11, 1]], text: './bar' }
]);
});
});
suite('macOS/Linux', () => {
setup(() => {
detector = instantiationService.createInstance(TerminalLocalLinkDetector, xterm, OperatingSystem.Linux, resolveLinkForTest);
});
for (const baseLink of unixLinks) {
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(TerminalBuiltinLinkType.LocalFile, formattedLink, [{ text: formattedLink, range: [[1, 1], [formattedLink.length, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, ` ${formattedLink} `, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `(${formattedLink})`, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `[${formattedLink}]`, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
});
}
});
}
test('Git diff links', async () => {
await assertLink(TerminalBuiltinLinkType.LocalFile, `diff --git a/foo/bar b/foo/bar`, [
{ text: 'foo/bar', range: [[14, 1], [20, 1]] },
{ text: 'foo/bar', range: [[24, 1], [30, 1]] }
]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `--- a/foo/bar`, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `+++ b/foo/bar`, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
});
});
suite('Windows', () => {
setup(() => {
detector = instantiationService.createInstance(TerminalLocalLinkDetector, xterm, OperatingSystem.Windows, resolveLinkForTest);
});
for (const baseLink of windowsLinks) {
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(TerminalBuiltinLinkType.LocalFile, formattedLink, [{ text: formattedLink, range: [[1, 1], [formattedLink.length, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, ` ${formattedLink} `, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `(${formattedLink})`, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `[${formattedLink}]`, [{ text: formattedLink, range: [[2, 1], [formattedLink.length + 1, 1]] }]);
});
}
});
}
test('Git diff links', async () => {
await assertLink(TerminalBuiltinLinkType.LocalFile, `diff --git a/foo/bar b/foo/bar`, [
{ text: 'foo/bar', range: [[14, 1], [20, 1]] },
{ text: 'foo/bar', range: [[24, 1], [30, 1]] }
]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `--- a/foo/bar`, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, `+++ b/foo/bar`, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
});
});
});

View file

@ -1,105 +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 { TerminalProtocolLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalProtocolLinkProvider';
import { Terminal, ILink } from 'xterm';
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';
import { URI } from 'vs/base/common/uri';
suite('Workbench - TerminalProtocolLinkProvider', () => {
let instantiationService: TestInstantiationService;
setup(() => {
instantiationService = new TestInstantiationService();
instantiationService.stub(IConfigurationService, TestConfigurationService);
});
async function assertLink(text: string, expected: { text: string; range: [number, number][] }[]) {
const xterm = new Terminal();
const provider = instantiationService.createInstance(TerminalProtocolLinkProvider, xterm, () => { }, () => { }, () => { }, (text: string, cb: (result: { uri: URI; isDirectory: boolean } | undefined) => void) => {
cb({ uri: URI.parse(text), isDirectory: false });
});
// Write the text and wait for the parser to finish
await new Promise<void>(r => xterm.write(text, r));
// Ensure all links are provided
const links = (await new Promise<ILink[] | undefined>(r => provider.provideLinks(1, r)))!;
assert.strictEqual(links.length, expected.length);
const actual = links.map(e => ({
text: e.text,
range: e.range
}));
const expectedVerbose = expected.map(e => ({
text: e.text,
range: {
start: { x: e.range[0][0], y: e.range[0][1] },
end: { x: e.range[1][0], y: e.range[1][1] },
}
}));
assert.deepStrictEqual(actual, expectedVerbose);
}
// 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 = <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('(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('<see http://foo.bar>', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink('<url>http://foo.bar</url>', [{ 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('<!-- !!! Do not remove !!! WebContentRef(link:https://go.microsoft.com/fwlink/?LinkId=166007, area:Admin, updated:2015, nextUpdate:2016, tags:SqlServer) !!! Do not remove !!! -->', [{ 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.</value>', [{ 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.</value>', [{ 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]' }]);
});
test('should support multiple link results', async () => {
await assertLink('http://foo.bar http://bar.foo', [
{ range: [[1, 1], [14, 1]], text: 'http://foo.bar' },
{ range: [[16, 1], [29, 1]], text: 'http://bar.foo' }
]);
});
test('should cap link size', async () => {
await assertLink([
'https://foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar',
'/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar'.repeat(69)]
.join(''),
[{
range: [[1, 1], [80, 26]],
text:
[
'https://foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar',
'/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar/foo.bar'.repeat(31),
'/'
].join('')
}]
);
});
});

View file

@ -0,0 +1,98 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalUriLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalUriLinkDetector';
import { assertLinkHelper, resolveLinkForTest } from 'vs/workbench/contrib/terminal/test/browser/links/linkTestUtils';
import { Terminal } from 'xterm';
suite('Workbench - TerminalUriLinkDetector', () => {
let configurationService: TestConfigurationService;
let detector: TerminalUriLinkDetector;
let xterm: Terminal;
setup(() => {
const instantiationService = new TestInstantiationService();
configurationService = new TestConfigurationService();
instantiationService.stub(IConfigurationService, configurationService);
xterm = new Terminal({ cols: 80, rows: 30 });
detector = instantiationService.createInstance(TerminalUriLinkDetector, xterm, resolveLinkForTest);
});
async function assertLink(
type: TerminalBuiltinLinkType,
text: string,
expected: (Pick<ITerminalSimpleLink, 'text'> & { range: [number, number][] })[]
) {
await assertLinkHelper(text, expected, detector, type);
}
test('LinkComputer cases', async () => {
await assertLink(TerminalBuiltinLinkType.Url, 'x = "http://foo.bar";', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = (http://foo.bar);', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = \'http://foo.bar\';', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = http://foo.bar ;', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = <http://foo.bar>;', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = {http://foo.bar};', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '(see http://foo.bar)', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '[see http://foo.bar]', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '{see http://foo.bar}', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '<see http://foo.bar>', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '<url>http://foo.bar</url>', [{ range: [[6, 1], [19, 1]], text: 'http://foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, '// 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(TerminalBuiltinLinkType.Url, '// 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(TerminalBuiltinLinkType.Url, '// 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(TerminalBuiltinLinkType.Url, '<!-- !!! Do not remove !!! WebContentRef(link:https://go.microsoft.com/fwlink/?LinkId=166007, area:Admin, updated:2015, nextUpdate:2016, tags:SqlServer) !!! Do not remove !!! -->', [{ range: [[49, 1], [14, 2]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'For instructions, see https://go.microsoft.com/fwlink/?LinkId=166007.</value>', [{ range: [[23, 1], [68, 1]], text: 'https://go.microsoft.com/fwlink/?LinkId=166007' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'For instructions, see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx.</value>', [{ range: [[23, 1], [21, 2]], text: 'https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'x = "https://en.wikipedia.org/wiki/Zürich";', [{ range: [[6, 1], [41, 1]], text: 'https://en.wikipedia.org/wiki/Zürich' }]);
await assertLink(TerminalBuiltinLinkType.Url, '請參閱 http://go.microsoft.com/fwlink/?LinkId=761051。', [{ range: [[8, 1], [53, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }]);
await assertLink(TerminalBuiltinLinkType.Url, '(請參閱 http://go.microsoft.com/fwlink/?LinkId=761051', [{ range: [[10, 1], [55, 1]], text: 'http://go.microsoft.com/fwlink/?LinkId=761051' }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, 'x = "file:///foo.bar";', [{ range: [[6, 1], [20, 1]], text: 'file:///foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, 'x = "file://c:/foo.bar";', [{ range: [[6, 1], [22, 1]], text: 'file://c:/foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, 'x = "file://shares/foo.bar";', [{ range: [[6, 1], [26, 1]], text: 'file://shares/foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.LocalFile, 'x = "file://shäres/foo.bar";', [{ range: [[6, 1], [26, 1]], text: 'file://shäres/foo.bar' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'Some text, then http://www.bing.com.', [{ range: [[17, 1], [35, 1]], text: 'http://www.bing.com' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'let url = `http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items`;', [{ range: [[12, 1], [78, 1]], text: 'http://***/_api/web/lists/GetByTitle(\'Teambuildingaanvragen\')/items' }]);
await assertLink(TerminalBuiltinLinkType.Url, '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(TerminalBuiltinLinkType.Url, 'let x = "http://[::1]:5000/connect/token"', [{ range: [[10, 1], [40, 1]], text: 'http://[::1]:5000/connect/token' }]);
await assertLink(TerminalBuiltinLinkType.Url, '2. Navigate to **https://portal.azure.com**', [{ range: [[18, 1], [41, 1]], text: 'https://portal.azure.com' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'POST|https://portal.azure.com|2019-12-05|', [{ range: [[6, 1], [29, 1]], text: 'https://portal.azure.com' }]);
await assertLink(TerminalBuiltinLinkType.Url, 'aa https://foo.bar/[this is foo site] aa', [{ range: [[5, 1], [38, 1]], text: 'https://foo.bar/[this is foo site]' }]);
});
test('should support multiple link results', async () => {
await assertLink(TerminalBuiltinLinkType.Url, 'http://foo.bar http://bar.foo', [
{ range: [[1, 1], [14, 1]], text: 'http://foo.bar' },
{ range: [[16, 1], [29, 1]], text: 'http://bar.foo' }
]);
});
test('should not filtrer out https:// link that exceed 1024 characters', async () => {
// 8 + 101 * 10 = 1018 characters
await assertLink(TerminalBuiltinLinkType.Url, `https://${'foobarbaz/'.repeat(101)}`, [{
range: [[1, 1], [58, 13]],
text: `https://${'foobarbaz/'.repeat(101)}`
}]);
// 8 + 102 * 10 = 1028 characters
await assertLink(TerminalBuiltinLinkType.Url, `https://${'foobarbaz/'.repeat(102)}`, [{
range: [[1, 1], [68, 13]],
text: `https://${'foobarbaz/'.repeat(102)}`
}]);
});
test('should filter out file:// links that exceed 1024 characters', async () => {
// 8 + 101 * 10 = 1018 characters
await assertLink(TerminalBuiltinLinkType.LocalFile, `file:///${'foobarbaz/'.repeat(101)}`, [{
text: `file:///${'foobarbaz/'.repeat(101)}`,
range: [[1, 1], [58, 13]]
}]);
// 8 + 102 * 10 = 1028 characters
await assertLink(TerminalBuiltinLinkType.LocalFile, `file:///${'foobarbaz/'.repeat(102)}`, []);
});
});

View file

@ -1,179 +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 { TerminalValidatedLocalLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalValidatedLocalLinkProvider';
import { Terminal, ILink } from 'xterm';
import { OperatingSystem } from 'vs/base/common/platform';
import { format } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
const unixLinks = [
'/foo',
'~/foo',
'./foo',
'./$foo',
'../foo',
'/foo/bar',
'/foo/bar+more',
'foo/bar',
'foo/bar+more',
];
const windowsLinks = [
'c:\\foo',
'\\\\?\\c:\\foo',
'c:/foo',
'.\\foo',
'./foo',
'./$foo',
'..\\foo',
'~\\foo',
'~/foo',
'c:/foo/bar',
'c:\\foo\\bar',
'c:\\foo\\bar+more',
'c:\\foo/bar\\baz',
'foo/bar',
'foo/bar',
'foo\\bar',
'foo\\bar+more',
];
class TestTerminalValidatedLocalLinkProvider extends TerminalValidatedLocalLinkProvider {
override _enableCaching: boolean = false;
}
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' },
{ urlFormat: '{0}\',{1}', line: '5' }
];
suite('Workbench - TerminalValidatedLocalLinkProvider', () => {
let instantiationService: TestInstantiationService;
setup(() => {
instantiationService = new TestInstantiationService();
instantiationService.stub(IConfigurationService, TestConfigurationService);
});
async function assertLink(text: string, os: OperatingSystem, expected: { text: string; range: [number, number][] }[]) {
const xterm = new Terminal();
const provider = instantiationService.createInstance(
TestTerminalValidatedLocalLinkProvider,
xterm,
os,
() => { },
() => { },
() => { },
(linkCandidates: string, cb: (result: { uri: URI; link: string; isDirectory: boolean } | undefined) => void) => {
cb({ uri: URI.file('/'), link: linkCandidates[0], isDirectory: false });
}
);
// Write the text and wait for the parser to finish
await new Promise<void>(r => xterm.write(text, r));
// Ensure all links are provided
const links = (await new Promise<ILink[] | undefined>(r => provider.provideLinks(1, r)))!;
assert.strictEqual(links.length, expected.length);
const actual = links.map(e => ({
text: e.text,
range: e.range
}));
const expectedVerbose = expected.map(e => ({
text: e.text,
range: {
start: { x: e.range[0][0], y: e.range[0][1] },
end: { x: e.range[1][0], y: e.range[1][1] },
}
}));
assert.deepStrictEqual(actual, expectedVerbose);
}
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]] }]);
});
}
});
});
test('Git diff links', async () => {
await assertLink(`diff --git a/foo/bar b/foo/bar`, OperatingSystem.Linux, [
{ text: 'foo/bar', range: [[14, 1], [20, 1]] },
{ text: 'foo/bar', range: [[24, 1], [30, 1]] }
]);
await assertLink(`--- a/foo/bar`, OperatingSystem.Linux, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
await assertLink(`+++ b/foo/bar`, OperatingSystem.Linux, [{ text: 'foo/bar', range: [[7, 1], [13, 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]] }]);
});
}
});
});
test('Git diff links', async () => {
await assertLink(`diff --git a/foo/bar b/foo/bar`, OperatingSystem.Linux, [
{ text: 'foo/bar', range: [[14, 1], [20, 1]] },
{ text: 'foo/bar', range: [[24, 1], [30, 1]] }
]);
await assertLink(`--- a/foo/bar`, OperatingSystem.Linux, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
await assertLink(`+++ b/foo/bar`, OperatingSystem.Linux, [{ text: 'foo/bar', range: [[7, 1], [13, 1]] }]);
});
});
test('should support multiple link results', async () => {
await assertLink('./foo ./bar', OperatingSystem.Linux, [
{ range: [[1, 1], [5, 1]], text: './foo' },
{ range: [[7, 1], [11, 1]], text: './bar' }
]);
});
});

View file

@ -0,0 +1,107 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminal/browser/links/links';
import { TerminalWordLinkDetector } from 'vs/workbench/contrib/terminal/browser/links/terminalWordLinkDetector';
import { assertLinkHelper } from 'vs/workbench/contrib/terminal/test/browser/links/linkTestUtils';
import { Terminal } from 'xterm';
suite('Workbench - TerminalWordLinkDetector', () => {
let configurationService: TestConfigurationService;
let detector: TerminalWordLinkDetector;
let xterm: Terminal;
setup(() => {
const instantiationService = new TestInstantiationService();
configurationService = new TestConfigurationService();
instantiationService.stub(IConfigurationService, configurationService);
xterm = new Terminal({ cols: 80, rows: 30 });
detector = instantiationService.createInstance(TerminalWordLinkDetector, xterm);
});
async function assertLink(
text: string,
expected: (Pick<ITerminalSimpleLink, 'text'> & { range: [number, number][] })[]
) {
await assertLinkHelper(text, expected, detector, TerminalBuiltinLinkType.Search);
}
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: [[1, 1], [3, 1]], text: 'foo' }]);
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}' }]);
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' []' } });
await assertLink('aabbccdd.txt ', [{ range: [[1, 1], [12, 1]], text: 'aabbccdd.txt' }]);
await assertLink(' aabbccdd.txt ', [{ range: [[2, 1], [13, 1]], text: 'aabbccdd.txt' }]);
await assertLink(' [aabbccdd.txt] ', [{ range: [[3, 1], [14, 1]], text: 'aabbccdd.txt' }]);
});
// These are failing - the link's start x is 1 px too far to the right bc it starts
// with a wide character, which the terminalLinkHelper currently doesn't account for
test.skip('should support wide characters', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' []' } });
await assertLink('我是学生.txt ', [{ range: [[1, 1], [12, 1]], text: '我是学生.txt' }]);
await assertLink(' 我是学生.txt ', [{ range: [[2, 1], [13, 1]], text: '我是学生.txt' }]);
await assertLink(' [我是学生.txt] ', [{ range: [[3, 1], [14, 1]], text: '我是学生.txt' }]);
});
test('should support multiple link results', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('foo bar', [
{ range: [[1, 1], [3, 1]], text: 'foo' },
{ range: [[5, 1], [7, 1]], text: 'bar' }
]);
});
test('should remove trailing colon in the link results', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('foo:5:6: bar:0:32:', [
{ range: [[1, 1], [7, 1]], text: 'foo:5:6' },
{ range: [[10, 1], [17, 1]], text: 'bar:0:32' }
]);
});
test('should support wrapping', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('fsdjfsdkfjslkdfjskdfjsldkfjsdlkfjslkdjfskldjflskdfjskldjflskdfjsdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd', [
{ range: [[1, 1], [41, 3]], text: 'fsdjfsdkfjslkdfjskdfjsldkfjsdlkfjslkdjfskldjflskdfjskldjflskdfjsdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd' },
]);
});
test('should support wrapping with multiple links', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('fsdjfsdkfjslkdfjskdfjsldkfj sdlkfjslkdjfskldjflskdfjskldjflskdfj sdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd', [
{ range: [[1, 1], [27, 1]], text: 'fsdjfsdkfjslkdfjskdfjsldkfj' },
{ range: [[29, 1], [64, 1]], text: 'sdlkfjslkdjfskldjflskdfjskldjflskdfj' },
{ range: [[66, 1], [43, 3]], text: 'sdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd' }
]);
});
test('does not return any links for empty text', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('', []);
});
test('should support file scheme links', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('file:///C:/users/test/file.txt ', [{ range: [[1, 1], [30, 1]], text: 'file:///C:/users/test/file.txt' }]);
await assertLink('file:///C:/users/test/file.txt:1:10 ', [{ range: [[1, 1], [35, 1]], text: 'file:///C:/users/test/file.txt:1:10' }]);
});
});

View file

@ -1,312 +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 { Terminal } 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';
import { IEditorOptions, ITextResourceEditorInput } from 'vs/platform/editor/common/editor';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { IStorageService } from 'vs/platform/storage/common/storage';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper';
import { ITerminalCommand, ITerminalConfigHelper, ITerminalConfiguration, IXtermMarker } from 'vs/workbench/contrib/terminal/common/terminal';
import { TestContextService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
import { TerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal';
import { TerminalLocation } from 'vs/platform/terminal/common/terminal';
import { TestViewDescriptorService } from 'vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { XtermLinkMatcherHandler } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager';
import { FileService } from 'vs/platform/files/common/fileService';
import { IResolveMetadataFileOptions, IFileStatWithMetadata, IResolveFileOptions, IFileStat, IFileService } from 'vs/platform/files/common/files';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
import { TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { EventType } from 'vs/base/browser/dom';
import { URI } from 'vs/base/common/uri';
import { TerminalLink } from 'vs/workbench/contrib/terminal/browser/links/terminalLink';
import { isWindows } from 'vs/base/common/platform';
import { TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { CommandDetectionCapability } from 'vs/workbench/contrib/terminal/browser/capabilities/commandDetectionCapability';
const defaultTerminalConfig: Partial<ITerminalConfiguration> = {
fontFamily: 'monospace',
fontWeight: 'normal',
fontWeightBold: 'normal',
gpuAcceleration: 'off',
scrollback: 1000,
fastScrollSensitivity: 2,
mouseWheelScrollSensitivity: 1,
unicodeVersion: '11',
wordSeparators: ' ()[]{}\',"`─‘’'
};
export interface ITerminalLinkActivationResult {
source: 'editor' | 'search';
link: string;
}
class TestFileService extends FileService {
private _files: string[] | '*' = '*';
override async resolve(resource: URI, options: IResolveMetadataFileOptions): Promise<IFileStatWithMetadata>;
override async resolve(resource: URI, options?: IResolveFileOptions): Promise<IFileStat>;
override async resolve(resource: URI, options?: IResolveFileOptions): Promise<IFileStat> {
if (this._files === '*' || this._files.includes(resource.fsPath)) {
return { isFile: true, isDirectory: false, isSymbolicLink: false } as IFileStat;
} else {
return { isFile: false, isDirectory: false, isSymbolicLink: false } as IFileStat;
}
}
setFiles(files: string[]): void {
this._files = files;
}
}
class TestCommandDetectionCapability extends CommandDetectionCapability {
setCommands(commands: ITerminalCommand[]) {
this._commands = commands;
}
}
suite('Workbench - TerminalWordLinkProvider', () => {
let instantiationService: TestInstantiationService;
let configurationService: TestConfigurationService;
let themeService: TestThemeService;
let fileService: TestFileService;
let viewDescriptorService: TestViewDescriptorService;
let xterm: XtermTerminal;
let configHelper: ITerminalConfigHelper;
let capabilities: TerminalCapabilityStore;
let activationResult: ITerminalLinkActivationResult | undefined;
let commandDetection: TestCommandDetectionCapability;
setup(() => {
instantiationService = new TestInstantiationService();
fileService = new TestFileService(new NullLogService());
configurationService = new TestConfigurationService();
instantiationService.stub(IConfigurationService, configurationService);
configurationService = new TestConfigurationService({
editor: {
fastScrollSensitivity: 2,
mouseWheelScrollSensitivity: 1
} as Partial<IEditorOptions>,
terminal: {
integrated: defaultTerminalConfig
}
});
themeService = new TestThemeService();
viewDescriptorService = new TestViewDescriptorService();
instantiationService = new TestInstantiationService();
instantiationService.stub(IWorkbenchEnvironmentService, {
remoteAuthority: undefined
} as Partial<IWorkbenchEnvironmentService>);
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IStorageService, new TestStorageService());
instantiationService.stub(IThemeService, themeService);
instantiationService.stub(IFileService, fileService);
instantiationService.stub(IViewDescriptorService, viewDescriptorService);
instantiationService.stub(IWorkspaceContextService, new TestContextService());
// Allow intercepting link activations
activationResult = undefined;
instantiationService.stub(IQuickInputService, {
quickAccess: {
show(link: string) {
activationResult = { link, source: 'search' };
}
}
} as Partial<IQuickInputService>);
instantiationService.stub(IEditorService, {
async openEditor(editor: ITextResourceEditorInput): Promise<any> {
activationResult = {
source: 'editor',
link: editor.resource?.toString()
};
}
} as Partial<IEditorService>);
configHelper = instantiationService.createInstance(TerminalConfigHelper);
capabilities = new TerminalCapabilityStore();
xterm = instantiationService.createInstance(XtermTerminal, Terminal, configHelper, 80, 30, TerminalLocation.Panel, capabilities);
commandDetection = new TestCommandDetectionCapability(xterm.raw, new NullLogService());
capabilities.add(TerminalCapability.CommandDetection, commandDetection);
});
async function assertLink(text: string, expected: { text: string; range: [number, number][]; linkActivationResult?: ITerminalLinkActivationResult }[], assertActicationResult?: boolean) {
xterm.dispose();
xterm = instantiationService.createInstance(XtermTerminal, Terminal, configHelper, 80, 30, TerminalLocation.Panel, capabilities);
// We don't want to cancel the event or anything from the tests so just pass in a wrapped
// link handler that does nothing.
const testWrappedLinkHandler = (handler: (event: MouseEvent | undefined, link: string) => void): XtermLinkMatcherHandler => {
return async (event: MouseEvent | undefined, link: string) => {
await handler(event, link);
};
};
const provider: TerminalWordLinkProvider = instantiationService.createInstance(TerminalWordLinkProvider,
xterm,
capabilities,
testWrappedLinkHandler,
() => { }
);
// Write the text and wait for the parser to finish
await new Promise<void>(r => xterm.raw.write(text, r));
// Ensure all links are provided
const links = (await new Promise<TerminalLink[] | undefined>(r => provider.provideLinks(1, r)))!;
const actualLinks = await Promise.all(links.map(async e => {
if (capabilities.has(TerminalCapability.CommandDetection)) {
e.activate(new TerminalLinkQuickPickEvent(EventType.CLICK), e.text);
await e.asyncActivate;
}
return {
text: e.text,
range: e.range,
activationResult
};
}));
const expectedVerbose = expected.map(e => ({
text: e.text,
range: {
start: { x: e.range[0][0], y: e.range[0][1] },
end: { x: e.range[1][0], y: e.range[1][1] },
},
activationResult: e.linkActivationResult
}));
assert.deepStrictEqual(
actualLinks.map(e => ({ text: e.text, range: e.range })),
expectedVerbose.map(e => ({ text: e.text, range: e.range }))
);
if (assertActicationResult) {
assert.deepStrictEqual(
actualLinks.map(e => e.activationResult),
expected.map(e => e.linkActivationResult)
);
}
assert.strictEqual(links.length, expected.length);
}
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: [[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}' }]);
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' []' } });
await assertLink('aabbccdd.txt ', [{ range: [[1, 1], [12, 1]], text: 'aabbccdd.txt' }]);
await assertLink(' aabbccdd.txt ', [{ range: [[2, 1], [13, 1]], text: 'aabbccdd.txt' }]);
await assertLink(' [aabbccdd.txt] ', [{ range: [[3, 1], [14, 1]], text: 'aabbccdd.txt' }]);
});
// These are failing - the link's start x is 1 px too far to the right bc it starts
// with a wide character, which the terminalLinkHelper currently doesn't account for
test.skip('should support wide characters', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' []' } });
await assertLink('我是学生.txt ', [{ range: [[1, 1], [12, 1]], text: '我是学生.txt' }]);
await assertLink(' 我是学生.txt ', [{ range: [[2, 1], [13, 1]], text: '我是学生.txt' }]);
await assertLink(' [我是学生.txt] ', [{ range: [[3, 1], [14, 1]], text: '我是学生.txt' }]);
});
test('should support multiple link results', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('foo bar', [
{ range: [[1, 1], [3, 1]], text: 'foo' },
{ range: [[5, 1], [7, 1]], text: 'bar' }
]);
});
test('should remove trailing colon in the link results', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('foo:5:6: bar:0:32:', [
{ range: [[1, 1], [7, 1]], text: 'foo:5:6' },
{ range: [[10, 1], [17, 1]], text: 'bar:0:32' }
]);
});
test('should support wrapping', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('fsdjfsdkfjslkdfjskdfjsldkfjsdlkfjslkdjfskldjflskdfjskldjflskdfjsdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd', [
{ range: [[1, 1], [41, 3]], text: 'fsdjfsdkfjslkdfjskdfjsldkfjsdlkfjslkdjfskldjflskdfjskldjflskdfjsdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd' },
]);
});
test('should support wrapping with multiple links', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('fsdjfsdkfjslkdfjskdfjsldkfj sdlkfjslkdjfskldjflskdfjskldjflskdfj sdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd', [
{ range: [[1, 1], [27, 1]], text: 'fsdjfsdkfjslkdfjskdfjsldkfj' },
{ range: [[29, 1], [64, 1]], text: 'sdlkfjslkdjfskldjflskdfjskldjflskdfj' },
{ range: [[66, 1], [43, 3]], text: 'sdklfjsdklfjsldkfjsdlkfjsdlkfjsdlkfjsldkfjslkdfjsdlkfjsldkfjsdlkfjskdfjsldkfjsdlkfjslkdfjsdlkfjsldkfjsldkfjsldkfjslkdfjsdlkfjslkdfjsdklfsd' }
]);
});
test('does not return any links for empty text', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('', []);
});
test('should support file scheme links', async () => {
await configurationService.setUserConfiguration('terminal', { integrated: { wordSeparators: ' ' } });
await assertLink('file:///C:/users/test/file.txt ', [{ range: [[1, 1], [30, 1]], text: 'file:///C:/users/test/file.txt' }]);
await assertLink('file:///C:/users/test/file.txt:1:10 ', [{ range: [[1, 1], [35, 1]], text: 'file:///C:/users/test/file.txt:1:10' }]);
});
test('should apply the cwd to the link only when the file exists and cwdDetection is enabled', async () => {
const cwd = (isWindows
? 'c:\\Users\\home\\folder'
: '/Users/home/folder'
);
const absoluteFile = (isWindows
? 'c:\\Users\\home\\folder\\file.txt'
: '/Users/home/folder/file.txt'
);
fileService.setFiles([absoluteFile]);
// Set a fake detected command starting as line 0 to establish the cwd
commandDetection.setCommands([{
command: '',
cwd,
timestamp: 0,
getOutput() { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any
}]);
await assertLink('file.txt', [{
range: [[1, 1], [8, 1]],
text: 'file.txt',
linkActivationResult: {
link: (isWindows
? 'file:///c%3A%5CUsers%5Chome%5Cfolder%5Cfile.txt'
: 'file:///Users/home/folder/file.txt'
),
source: 'editor'
}
}]);
// Clear deteceted commands and ensure the same request results in a search
commandDetection.setCommands([]);
await assertLink('file.txt', [{
range: [[1, 1], [8, 1]],
text: 'file.txt',
linkActivationResult: { link: 'file.txt', source: 'search' }
}]);
});
});