Skip to content

Commit

Permalink
Implement terminal link handler API
Browse files Browse the repository at this point in the history
Part of #91606
  • Loading branch information
Tyriar committed Mar 13, 2020
1 parent 2654576 commit b66d566
Show file tree
Hide file tree
Showing 10 changed files with 219 additions and 25 deletions.
16 changes: 16 additions & 0 deletions src/vs/vscode.proposed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,22 @@ declare module 'vscode' {

//#endregion

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

export namespace window {
export function registerTerminalLinkHandler(handler: TerminalLinkHandler): Disposable;
}

export interface TerminalLinkHandler {
/**
* @return true when the link was handled (and should not be considered by
* other providers including the default), false when the link was not handled.
*/
handleLink(terminal: Terminal, link: string): ProviderResult<boolean>;
}

//#endregion

//#region Joh -> exclusive document filters

export interface DocumentFilter {
Expand Down
23 changes: 21 additions & 2 deletions src/vs/workbench/api/browser/mainThreadTerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { DisposableStore, Disposable } from 'vs/base/common/lifecycle';
import { DisposableStore, Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { IShellLaunchConfig, ITerminalProcessExtHostProxy, ISpawnExtHostProcessRequest, ITerminalDimensions, EXT_HOST_CREATION_DELAY, IAvailableShellsRequest, IDefaultShellAndArgsRequest, IStartExtensionTerminalRequest } from 'vs/workbench/contrib/terminal/common/terminal';
import { ExtHostContext, ExtHostTerminalServiceShape, MainThreadTerminalServiceShape, MainContext, IExtHostContext, IShellLaunchConfigDto, TerminalLaunchConfig, ITerminalDimensionsDto } from 'vs/workbench/api/common/extHost.protocol';
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 } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalInstanceService, ITerminalService, ITerminalInstance, ITerminalBeforeHandleLinkEvent } 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 @@ -23,6 +23,7 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape
private readonly _terminalProcesses = new Map<number, Promise<ITerminalProcessExtHostProxy>>();
private readonly _terminalProcessesReady = new Map<number, (proxy: ITerminalProcessExtHostProxy) => void>();
private _dataEventTracker: TerminalDataEventTracker | undefined;
private _linkHandler: IDisposable | undefined;

constructor(
extHostContext: IExtHostContext,
Expand Down Expand Up @@ -146,6 +147,24 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape
}
}

public $startHandlingLinks(): void {
console.log('start');
this._linkHandler?.dispose();
this._linkHandler = this._terminalService.addLinkHandler(this._remoteAuthority || '', e => this._handleLink(e));
}

public $stopHandlingLinks(): void {
console.log('stop');
this._linkHandler?.dispose();
}

private async _handleLink(e: ITerminalBeforeHandleLinkEvent): Promise<boolean> {
if (!e.terminal) {
return false;
}
return this._proxy.$handleLink(e.terminal.id, e.link);
}

private _onActiveTerminalChanged(terminalId: number | null): void {
this._proxy.$acceptActiveTerminalChanged(terminalId);
}
Expand Down
4 changes: 4 additions & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
}
return extHostTerminalService.createTerminal(nameOrOptions, shellPath, shellArgs);
},
registerTerminalLinkHandler(handler: vscode.TerminalLinkHandler): vscode.Disposable {
checkProposedApiEnabled(extension);
return extHostTerminalService.registerLinkHandler(handler);
},
registerTreeDataProvider(viewId: string, treeDataProvider: vscode.TreeDataProvider<any>): vscode.Disposable {
return extHostTreeViews.registerTreeDataProvider(viewId, treeDataProvider, extension);
},
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,8 @@ export interface MainThreadTerminalServiceShape extends IDisposable {
$show(terminalId: number, preserveFocus: boolean): void;
$startSendingDataEvents(): void;
$stopSendingDataEvents(): void;
$startHandlingLinks(): void;
$stopHandlingLinks(): void;

// Process
$sendProcessTitle(terminalId: number, title: string): void;
Expand Down Expand Up @@ -1310,6 +1312,7 @@ export interface ExtHostTerminalServiceShape {
$acceptWorkspacePermissionsChanged(isAllowed: boolean): void;
$getAvailableShells(): Promise<IShellDefinitionDto[]>;
$getDefaultShellAndArgs(useAutomationShell: boolean): Promise<IShellAndArgsDto>;
$handleLink(id: number, link: string): Promise<boolean>;
}

export interface ExtHostSCMShape {
Expand Down
35 changes: 35 additions & 0 deletions src/vs/workbench/api/common/extHostTerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { timeout } from 'vs/base/common/async';
import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService';
import { TerminalDataBufferer } from 'vs/workbench/contrib/terminal/common/terminalDataBuffering';
import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Disposable as VSCodeDisposable } from './extHostTypes';

export interface IExtHostTerminalService extends ExtHostTerminalServiceShape {

Expand All @@ -34,6 +35,7 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape {
attachPtyToTerminal(id: number, pty: vscode.Pseudoterminal): void;
getDefaultShell(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string;
getDefaultShellArgs(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string[] | string;
registerLinkHandler(handler: vscode.TerminalLinkHandler): vscode.Disposable
}

export const IExtHostTerminalService = createDecorator<IExtHostTerminalService>('IExtHostTerminalService');
Expand Down Expand Up @@ -535,6 +537,39 @@ export abstract class BaseExtHostTerminalService implements IExtHostTerminalServ
return id;
}

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

public async $handleLink(id: number, link: string): Promise<boolean> {
const terminal = this._getTerminalById(id);
if (!terminal) {
return false;
}

// Call each handler synchronously so multiple handlers aren't triggered at once
const it = this._linkHandlers.values();
let next = it.next();
while (!next.done) {
const handled = await next.value.handleLink(terminal, link);
if (handled) {
return true;
}
next = it.next();
}
return false;
}

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

Expand Down
25 changes: 25 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ export interface ITerminalService {
findNext(): void;
findPrevious(): void;

/**
* Link handlers can be registered here to allow intercepting links clicked in the terminal.
* When a link is clicked, the link will be considered handled when the first interceptor
* resolves with true. It will be considered not handled when _all_ link handlers resolve with
* false, or 3 seconds have elapsed.
*/
addLinkHandler(key: string, callback: TerminalLinkHandlerCallback): IDisposable;

selectDefaultWindowsShell(): Promise<void>;

setContainers(panelContainer: HTMLElement, terminalContainer: HTMLElement): void;
Expand Down Expand Up @@ -179,6 +187,18 @@ export enum WindowsShellType {
}
export type TerminalShellType = WindowsShellType | undefined;

export const LINK_INTERCEPT_THRESHOLD = 3000;

export interface ITerminalBeforeHandleLinkEvent {
terminal?: ITerminalInstance;
/** The text of the link */
link: string;
/** Call with whether the link was handled by the interceptor */
resolve(wasHandled: boolean): void;
}

export type TerminalLinkHandlerCallback = (e: ITerminalBeforeHandleLinkEvent) => Promise<boolean>;

export interface ITerminalInstance {
/**
* The ID of the terminal instance, this is an arbitrary number only used to identify the
Expand Down Expand Up @@ -240,6 +260,11 @@ export interface ITerminalInstance {
*/
onExit: Event<number | undefined>;

/**
* Attach a listener to intercept and handle link clicks in the terminal.
*/
onBeforeHandleLink: Event<ITerminalBeforeHandleLinkEvent>;

readonly exitCode: number | undefined;

processReady: Promise<void>;
Expand Down
31 changes: 19 additions & 12 deletions src/vs/workbench/contrib/terminal/browser/terminalInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { ansiColorIdentifiers, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGR
import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper';
import { TerminalLinkHandler } from 'vs/workbench/contrib/terminal/browser/terminalLinkHandler';
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
import { ITerminalInstanceService, ITerminalInstance, TerminalShellType } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalInstanceService, ITerminalInstance, TerminalShellType, ITerminalBeforeHandleLinkEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager';
import { Terminal as XTermTerminal, IBuffer, ITerminalAddon } from 'xterm';
import { SearchAddon, ISearchOptions } from 'xterm-addon-search';
Expand Down Expand Up @@ -250,28 +250,30 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
public get commandTracker(): CommandTrackerAddon | undefined { return this._commandTrackerAddon; }
public get navigationMode(): INavigationMode | undefined { return this._navigationModeAddon; }

private readonly _onExit = new Emitter<number | undefined>();
private readonly _onExit = this._register(new Emitter<number | undefined>());
public get onExit(): Event<number | undefined> { return this._onExit.event; }
private readonly _onDisposed = new Emitter<ITerminalInstance>();
private readonly _onDisposed = this._register(new Emitter<ITerminalInstance>());
public get onDisposed(): Event<ITerminalInstance> { return this._onDisposed.event; }
private readonly _onFocused = new Emitter<ITerminalInstance>();
private readonly _onFocused = this._register(new Emitter<ITerminalInstance>());
public get onFocused(): Event<ITerminalInstance> { return this._onFocused.event; }
private readonly _onProcessIdReady = new Emitter<ITerminalInstance>();
private readonly _onProcessIdReady = this._register(new Emitter<ITerminalInstance>());
public get onProcessIdReady(): Event<ITerminalInstance> { return this._onProcessIdReady.event; }
private readonly _onTitleChanged = new Emitter<ITerminalInstance>();
private readonly _onTitleChanged = this._register(new Emitter<ITerminalInstance>());
public get onTitleChanged(): Event<ITerminalInstance> { return this._onTitleChanged.event; }
private readonly _onData = new Emitter<string>();
private readonly _onData = this._register(new Emitter<string>());
public get onData(): Event<string> { return this._onData.event; }
private readonly _onLineData = new Emitter<string>();
private readonly _onLineData = this._register(new Emitter<string>());
public get onLineData(): Event<string> { return this._onLineData.event; }
private readonly _onRequestExtHostProcess = new Emitter<ITerminalInstance>();
private readonly _onRequestExtHostProcess = this._register(new Emitter<ITerminalInstance>());
public get onRequestExtHostProcess(): Event<ITerminalInstance> { return this._onRequestExtHostProcess.event; }
private readonly _onDimensionsChanged = new Emitter<void>();
private readonly _onDimensionsChanged = this._register(new Emitter<void>());
public get onDimensionsChanged(): Event<void> { return this._onDimensionsChanged.event; }
private readonly _onMaximumDimensionsChanged = new Emitter<void>();
private readonly _onMaximumDimensionsChanged = this._register(new Emitter<void>());
public get onMaximumDimensionsChanged(): Event<void> { return this._onMaximumDimensionsChanged.event; }
private readonly _onFocus = new Emitter<ITerminalInstance>();
private readonly _onFocus = this._register(new Emitter<ITerminalInstance>());
public get onFocus(): Event<ITerminalInstance> { return this._onFocus.event; }
private readonly _onBeforeHandleLink = this._register(new Emitter<ITerminalBeforeHandleLinkEvent>());
public get onBeforeHandleLink(): Event<ITerminalBeforeHandleLinkEvent> { return this._onBeforeHandleLink.event; }

public constructor(
private readonly _terminalFocusContextKey: IContextKey<boolean>,
Expand Down Expand Up @@ -523,6 +525,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
});
}
this._linkHandler = this._instantiationService.createInstance(TerminalLinkHandler, xterm, this._processManager, this._configHelper);
this._linkHandler.onBeforeHandleLink(e => {
console.log('terminalinstance fire');
e.terminal = this;
this._onBeforeHandleLink.fire(e);
});
});

this._commandTrackerAddon = new CommandTrackerAddon();
Expand Down
45 changes: 42 additions & 3 deletions src/vs/workbench/contrib/terminal/browser/terminalLinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import { IFileService } from 'vs/platform/files/common/files';
import { Terminal, ILinkMatcherOptions, IViewportRange } from 'xterm';
import { REMOTE_HOST_SCHEME } from 'vs/platform/remote/common/remoteHosts';
import { posix, win32 } from 'vs/base/common/path';
import { ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalInstanceService, ITerminalBeforeHandleLinkEvent, LINK_INTERCEPT_THRESHOLD } from 'vs/workbench/contrib/terminal/browser/terminal';
import { OperatingSystem, isMacintosh } from 'vs/base/common/platform';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { Emitter, Event } from 'vs/base/common/event';
import { ILogService } from 'vs/platform/log/common/log';

const pathPrefix = '(\\.\\.?|\\~)';
const pathSeparatorClause = '\\/';
Expand Down Expand Up @@ -74,6 +76,18 @@ export class TerminalLinkHandler {
private _gitDiffPostImagePattern: RegExp;
private readonly _tooltipCallback: (event: MouseEvent, uri: string, location: IViewportRange) => boolean | void;
private readonly _leaveCallback: () => void;
private _hasBeforeHandleLinkListeners = false;

private readonly _onBeforeHandleLink = new Emitter<ITerminalBeforeHandleLinkEvent>({
onFirstListenerAdd: () => this._hasBeforeHandleLinkListeners = true,
onLastListenerRemove: () => this._hasBeforeHandleLinkListeners = false
});
/**
* Allows intercepting links and handling them outside of the default link handler. When fired
* the listener has a set amount of time to handle the link or the default handler will fire.
* This was designed to only be handled by a single listener.
*/
public get onBeforeHandleLink(): Event<ITerminalBeforeHandleLinkEvent> { return this._onBeforeHandleLink.event; }

constructor(
private _xterm: Terminal,
Expand All @@ -83,7 +97,8 @@ export class TerminalLinkHandler {
@IEditorService private readonly _editorService: IEditorService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService,
@IFileService private readonly _fileService: IFileService
@IFileService private readonly _fileService: IFileService,
@ILogService private readonly _logService: ILogService
) {
// Matches '--- a/src/file1', capturing 'src/file1' in group 1
this._gitDiffPreImagePattern = /^--- a\/(\S*)/;
Expand Down Expand Up @@ -213,6 +228,7 @@ export class TerminalLinkHandler {

public dispose(): void {
this._hoverDisposables.dispose();
this._onBeforeHandleLink.dispose();
}

private _wrapLinkHandler(handler: (uri: string) => boolean | void): XtermLinkMatcherHandler {
Expand Down Expand Up @@ -245,10 +261,33 @@ export class TerminalLinkHandler {
}

private _handleLocalLink(link: string): PromiseLike<any> {
return this._resolvePath(link).then(resolvedLink => {
return this._resolvePath(link).then(async resolvedLink => {
if (!resolvedLink) {
return Promise.resolve(null);
}

// Allow the link to be intercepted if there are listeners
if (this._hasBeforeHandleLinkListeners) {
const wasHandled = await new Promise<boolean>(r => {
const timeoutId = setTimeout(() => {
canceled = true;
this._logService.error('An extension intecepted a terminal link but did not return');
r(false);
}, LINK_INTERCEPT_THRESHOLD);
let canceled = false;
const resolve = (handled: boolean) => {
if (!canceled) {
clearTimeout(timeoutId);
r(handled);
}
};
this._onBeforeHandleLink.fire({ link, resolve });
});
if (wasHandled) {
return;
}
}

const lineColumnInfo: LineColumnInfo = this.extractLineColumnInfo(link);
const selection: ITextEditorSelection = {
startLineNumber: lineColumnInfo.lineNumber,
Expand Down
Loading

0 comments on commit b66d566

Please sign in to comment.