Skip to content

Commit

Permalink
Merge pull request #100496 from microsoft/tyriar/link_providers
Browse files Browse the repository at this point in the history
Proposed terminal link provider API
  • Loading branch information
Tyriar committed Jun 22, 2020
2 parents fad4d8d + a560acd commit 269f0fd
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 49 deletions.
58 changes: 58 additions & 0 deletions src/vs/vscode.proposed.d.ts
Expand Up @@ -1054,6 +1054,64 @@ declare module 'vscode' {

//#endregion

//#region Terminal link provider https://github.com/microsoft/vscode/issues/91606

export namespace window {
export function registerTerminalLinkProvider(provider: TerminalLinkProvider): Disposable;
}

export interface TerminalLinkContext {
/**
* This is the text from the unwrapped line in the terminal.
*/
line: string;

/**
* The terminal the link belongs to.
*/
terminal: Terminal;
}

export interface TerminalLinkProvider<T = TerminalLink> {
provideTerminalLinks(context: TerminalLinkContext): ProviderResult<T[]>

/**
* Handle an activated terminal link.
*
* @returns Whether the link was handled, if not VS Code will attempt to open it.
*/
handleTerminalLink(link: T): ProviderResult<boolean>;
}

export interface TerminalLink {
/**
* The start index of the link on [TerminalLinkContext.line](#TerminalLinkContext.line].
*/
startIndex: number;

/**
* The length of the link on [TerminalLinkContext.line](#TerminalLinkContext.line]
*/
length: number;

/**
* The uri this link points to. If set, and {@link TerminalLinkProvider.handlerTerminalLink}
* is not implemented or returns false, then VS Code will try to open the Uri.
*/
target?: Uri;

/**
* The tooltip text when you hover over this link.
*
* If a tooltip is provided, is will be displayed in a string that includes instructions on
* how to trigger the link, such as `{0} (ctrl + click)`. The specific instructions vary
* depending on OS, user settings, and localization.
*/
tooltip?: string;
}

//#endregion

//#region @jrieken -> exclusive document filters

export interface DocumentFilter {
Expand Down
41 changes: 40 additions & 1 deletion src/vs/workbench/api/browser/mainThreadTerminalService.ts
Expand Up @@ -9,7 +9,7 @@ import { ExtHostContext, ExtHostTerminalServiceShape, MainThreadTerminalServiceS
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
import { URI } from 'vs/base/common/uri';
import { StopWatch } from 'vs/base/common/stopwatch';
import { ITerminalInstanceService, ITerminalService, ITerminalInstance, ITerminalBeforeHandleLinkEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalInstanceService, ITerminalService, ITerminalInstance, ITerminalBeforeHandleLinkEvent, ITerminalExternalLinkProvider, ITerminalLink } from 'vs/workbench/contrib/terminal/browser/terminal';
import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { TerminalDataBufferer } from 'vs/workbench/contrib/terminal/common/terminalDataBuffering';
Expand All @@ -25,6 +25,13 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape
private readonly _terminalProcessProxies = new Map<number, ITerminalProcessExtHostProxy>();
private _dataEventTracker: TerminalDataEventTracker | undefined;
private _linkHandler: IDisposable | undefined;
/**
* A single shared terminal link provider for the exthost. When an ext registers a link
* provider, this is registered with the terminal on the renderer side and all links are
* provided through this, even from multiple ext link providers. Xterm should remove lower
* priority intersecting links itself.
*/
private _linkProvider: IDisposable | undefined;

constructor(
extHostContext: IExtHostContext,
Expand Down Expand Up @@ -86,6 +93,8 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape

public dispose(): void {
this._toDispose.dispose();
this._linkHandler?.dispose();
this._linkProvider?.dispose();

// TODO@Daniel: Should all the previously created terminals be disposed
// when the extension host process goes down ?
Expand Down Expand Up @@ -162,6 +171,17 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape

public $stopHandlingLinks(): void {
this._linkHandler?.dispose();
this._linkHandler = undefined;
}

public $startLinkProvider(): void {
this._linkProvider?.dispose();
this._linkProvider = this._terminalService.registerLinkProvider(new ExtensionTerminalLinkProvider(this._proxy));
}

public $stopLinkProvider(): void {
this._linkProvider?.dispose();
this._linkProvider = undefined;
}

private async _handleLink(e: ITerminalBeforeHandleLinkEvent): Promise<boolean> {
Expand Down Expand Up @@ -395,3 +415,22 @@ class TerminalDataEventTracker extends Disposable {
this._register(this._bufferer.startBuffering(instance.id, instance.onData));
}
}

class ExtensionTerminalLinkProvider implements ITerminalExternalLinkProvider {
constructor(
private readonly _proxy: ExtHostTerminalServiceShape
) {
}

async provideLinks(instance: ITerminalInstance, line: string): Promise<ITerminalLink[] | undefined> {
const proxy = this._proxy;
const extHostLinks = await proxy.$provideLinks(instance.id, line);
return extHostLinks.map(dto => ({
id: dto.id,
startIndex: dto.startIndex,
length: dto.length,
label: dto.label,
activate: () => proxy.$activateLink(instance.id, dto.id)
}));
}
}
4 changes: 4 additions & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Expand Up @@ -587,6 +587,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
checkProposedApiEnabled(extension);
return extHostTerminalService.registerLinkHandler(handler);
},
registerTerminalLinkProvider(handler: vscode.TerminalLinkProvider): vscode.Disposable {
checkProposedApiEnabled(extension);
return extHostTerminalService.registerLinkProvider(handler);
},
registerTreeDataProvider(viewId: string, treeDataProvider: vscode.TreeDataProvider<any>): vscode.Disposable {
return extHostTreeViews.registerTreeDataProvider(viewId, treeDataProvider, extension);
},
Expand Down
15 changes: 15 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Expand Up @@ -450,6 +450,8 @@ export interface MainThreadTerminalServiceShape extends IDisposable {
$stopSendingDataEvents(): void;
$startHandlingLinks(): void;
$stopHandlingLinks(): void;
$startLinkProvider(): void;
$stopLinkProvider(): void;
$setEnvironmentVariableCollection(extensionIdentifier: string, persistent: boolean, collection: ISerializableEnvironmentVariableCollection | undefined): void;

// Process
Expand Down Expand Up @@ -1380,6 +1382,17 @@ export interface IShellAndArgsDto {
args: string[] | string | undefined;
}

export interface ITerminalLinkDto {
/** The ID of the link to enable activation and disposal. */
id: number;
/** The startIndex of the link in the line. */
startIndex: number;
/** The length of the link in the line. */
length: number;
/** The descriptive label for what the link does when activated. */
label?: string;
}

export interface ITerminalDimensionsDto {
columns: number;
rows: number;
Expand All @@ -1406,6 +1419,8 @@ export interface ExtHostTerminalServiceShape {
$getAvailableShells(): Promise<IShellDefinitionDto[]>;
$getDefaultShellAndArgs(useAutomationShell: boolean): Promise<IShellAndArgsDto>;
$handleLink(id: number, link: string): Promise<boolean>;
$provideLinks(id: number, line: string): Promise<ITerminalLinkDto[]>;
$activateLink(id: number, linkId: number): void;
$initEnvironmentVariableCollections(collections: [string, ISerializableEnvironmentVariableCollection][]): void;
}

Expand Down
85 changes: 82 additions & 3 deletions src/vs/workbench/api/common/extHostTerminalService.ts
Expand Up @@ -5,7 +5,7 @@

import type * as vscode from 'vscode';
import { Event, Emitter } from 'vs/base/common/event';
import { ExtHostTerminalServiceShape, MainContext, MainThreadTerminalServiceShape, IShellLaunchConfigDto, IShellDefinitionDto, IShellAndArgsDto, ITerminalDimensionsDto } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostTerminalServiceShape, MainContext, MainThreadTerminalServiceShape, IShellLaunchConfigDto, IShellDefinitionDto, IShellAndArgsDto, ITerminalDimensionsDto, ITerminalLinkDto } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostConfigProvider } from 'vs/workbench/api/common/extHostConfiguration';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { URI, UriComponents } from 'vs/base/common/uri';
Expand Down Expand Up @@ -39,6 +39,7 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape {
getDefaultShell(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string;
getDefaultShellArgs(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string[] | string;
registerLinkHandler(handler: vscode.TerminalLinkHandler): vscode.Disposable;
registerLinkProvider(provider: vscode.TerminalLinkProvider): vscode.Disposable;
getEnvironmentVariableCollection(extension: IExtensionDescription, persistent?: boolean): vscode.EnvironmentVariableCollection;
}

Expand Down Expand Up @@ -293,6 +294,13 @@ export class ExtHostPseudoterminal implements ITerminalChildProcess {
}
}

let nextLinkId = 1;

interface ICachedLinkEntry {
provider: vscode.TerminalLinkProvider;
link: vscode.TerminalLink;
}

export abstract class BaseExtHostTerminalService implements IExtHostTerminalService, ExtHostTerminalServiceShape {

readonly _serviceBrand: undefined;
Expand All @@ -307,6 +315,8 @@ export abstract class BaseExtHostTerminalService implements IExtHostTerminalServ

private readonly _bufferer: TerminalDataBufferer;
private readonly _linkHandlers: Set<vscode.TerminalLinkHandler> = new Set();
private readonly _linkProviders: Set<vscode.TerminalLinkProvider> = new Set();
private readonly _terminalLinkCache: Map<number, Map<number, ICachedLinkEntry>> = new Map();

public get activeTerminal(): ExtHostTerminal | undefined { return this._activeTerminal; }
public get terminals(): ExtHostTerminal[] { return this._terminals; }
Expand Down Expand Up @@ -547,17 +557,30 @@ export abstract class BaseExtHostTerminalService implements IExtHostTerminalServ

public registerLinkHandler(handler: vscode.TerminalLinkHandler): vscode.Disposable {
this._linkHandlers.add(handler);
if (this._linkHandlers.size === 1) {
if (this._linkHandlers.size === 1 && this._linkProviders.size === 0) {
this._proxy.$startHandlingLinks();
}
return new VSCodeDisposable(() => {
this._linkHandlers.delete(handler);
if (this._linkHandlers.size === 0) {
if (this._linkHandlers.size === 0 && this._linkProviders.size === 0) {
this._proxy.$stopHandlingLinks();
}
});
}

public registerLinkProvider(provider: vscode.TerminalLinkProvider): vscode.Disposable {
this._linkProviders.add(provider);
if (this._linkProviders.size === 1) {
this._proxy.$startLinkProvider();
}
return new VSCodeDisposable(() => {
this._linkProviders.delete(provider);
if (this._linkProviders.size === 0) {
this._proxy.$stopLinkProvider();
}
});
}

public async $handleLink(id: number, link: string): Promise<boolean> {
const terminal = this._getTerminalById(id);
if (!terminal) {
Expand All @@ -577,6 +600,62 @@ export abstract class BaseExtHostTerminalService implements IExtHostTerminalServ
return false;
}

public async $provideLinks(terminalId: number, line: string): Promise<ITerminalLinkDto[]> {
const terminal = this._getTerminalById(terminalId);
if (!terminal) {
return [];
}

// Discard any cached links the terminal has been holding, currently all links are released
// when new links are provided.
this._terminalLinkCache.delete(terminalId);

const result: ITerminalLinkDto[] = [];
const context: vscode.TerminalLinkContext = { terminal, line };
const promises: vscode.ProviderResult<{ provider: vscode.TerminalLinkProvider, links: vscode.TerminalLink[] }>[] = [];
for (const provider of this._linkProviders) {
promises.push(new Promise(async r => {
const links = (await provider.provideTerminalLinks(context)) || [];
r({ provider, links });
}));
}

const provideResults = await Promise.all(promises);
const cacheLinkMap = new Map<number, ICachedLinkEntry>();
for (const provideResult of provideResults) {
if (provideResult && provideResult.links.length > 0) {
result.push(...provideResult.links.map(providerLink => {
const link = {
id: nextLinkId++,
startIndex: providerLink.startIndex,
length: providerLink.length,
label: providerLink.tooltip
};
cacheLinkMap.set(link.id, {
provider: provideResult.provider,
link: providerLink
});
return link;
}));
}
}

this._terminalLinkCache.set(terminalId, cacheLinkMap);

return result;
}

$activateLink(terminalId: number, linkId: number): void {
const cachedLink = this._terminalLinkCache.get(terminalId)?.get(linkId);
if (!cachedLink) {
return;
}
cachedLink.provider.handleTerminalLink(cachedLink.link);
// TODO: Handle when result is false? Should this be return void instead and remove
// TerminalLink.target? It's a simple call to window.openUri for the extension otherwise
// and would simplify the API.
}

private _onProcessExit(id: number, exitCode: number | undefined): void {
this._bufferer.stopBuffering(id);

Expand Down
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Terminal, IViewportRange, IBufferLine } from 'xterm';
import { getXtermLineContent, convertLinkRangeToBuffer } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers';
import { TerminalLink } from 'vs/workbench/contrib/terminal/browser/links/terminalLink';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { TerminalBaseLinkProvider } from 'vs/workbench/contrib/terminal/browser/links/terminalBaseLinkProvider';
import { ITerminalExternalLinkProvider, ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal';

/**
* An adapter to convert a simple external link provider into an internal link provider that
* manages link lifecycle, hovers, etc. and gets registered in xterm.js.
*/
export class TerminalExternalLinkProviderAdapter extends TerminalBaseLinkProvider {

constructor(
private readonly _xterm: Terminal,
private readonly _instance: ITerminalInstance,
private readonly _externalLinkProvider: ITerminalExternalLinkProvider,
private readonly _tooltipCallback: (link: TerminalLink, viewportRange: IViewportRange, modifierDownCallback?: () => void, modifierUpCallback?: () => void) => void,
@IInstantiationService private readonly _instantiationService: IInstantiationService
) {
super();
}

protected async _provideLinks(y: number): Promise<TerminalLink[]> {
let startLine = y - 1;
let endLine = startLine;

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

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

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

const lineContent = getXtermLineContent(this._xterm.buffer.active, startLine, endLine, this._xterm.cols);
const externalLinks = await this._externalLinkProvider.provideLinks(this._instance, lineContent);
if (!externalLinks) {
return [];
}

return 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 = lineContent.substr(link.startIndex, link.length) || '';
return this._instantiationService.createInstance(TerminalLink, bufferRange, matchingText, this._xterm.buffer.active.viewportY, (_, text) => link.activate(text), this._tooltipCallback, true, link.label);
});
}
}

0 comments on commit 269f0fd

Please sign in to comment.