add basic contextual commands (#160952)

This commit is contained in:
Megan Rogge
2022-09-22 15:30:47 -07:00
committed by GitHub
parent b95793e6f7
commit de450f991c
27 changed files with 849 additions and 59 deletions
@@ -220,6 +220,7 @@ export interface ITerminalCommand {
commandStartLineContent?: string;
markProperties?: IMarkProperties;
getOutput(): string | undefined;
getOutputMatch(outputMatcher: { lineMatcher: string | RegExp; anchor?: 'top' | 'bottom'; offset?: number; length?: number }): RegExpMatchArray | undefined;
hasOutput(): boolean;
}
@@ -8,6 +8,7 @@ import { debounce } from 'vs/base/common/decorators';
import { Emitter } from 'vs/base/common/event';
import { ILogService } from 'vs/platform/log/common/log';
import { ICommandDetectionCapability, TerminalCapability, ITerminalCommand, IHandleCommandOptions, ICommandInvalidationRequest, CommandInvalidationReason, ISerializedCommand, ISerializedCommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
// Importing types is safe in any layer
// eslint-disable-next-line local/code-import-patterns
import type { IBuffer, IBufferLine, IDisposable, IMarker, Terminal } from 'xterm-headless';
@@ -485,6 +486,7 @@ export class CommandDetectionCapability implements ICommandDetectionCapability {
commandStartLineContent: this._currentCommand.commandStartLineContent,
hasOutput: () => !executedMarker?.isDisposed && !endMarker?.isDisposed && !!(executedMarker && endMarker && executedMarker?.line < endMarker!.line),
getOutput: () => getOutputForCommand(executedMarker, endMarker, buffer),
getOutputMatch: (outputMatcher?: { lineMatcher: string | RegExp; anchor?: 'top' | 'bottom'; offset?: number; length?: number }) => getOutputMatchForCommand(executedMarker, endMarker, buffer, this._terminal.cols, outputMatcher),
markProperties: options?.markProperties
};
this._commands.push(newCommand);
@@ -609,6 +611,7 @@ export class CommandDetectionCapability implements ICommandDetectionCapability {
exitCode: e.exitCode,
hasOutput: () => !executedMarker?.isDisposed && !endMarker?.isDisposed && !!(executedMarker && endMarker && executedMarker.line < endMarker.line),
getOutput: () => getOutputForCommand(executedMarker, endMarker, buffer),
getOutputMatch: (outputMatcher: { lineMatcher: string | RegExp; anchor?: 'top' | 'bottom'; offset?: number; length?: number }) => getOutputMatchForCommand(executedMarker, endMarker, buffer, this._terminal.cols, outputMatcher),
markProperties: e.markProperties
};
this._commands.push(newCommand);
@@ -639,3 +642,59 @@ function getOutputForCommand(executedMarker: IMarker | undefined, endMarker: IMa
}
return output === '' ? undefined : output;
}
export function getOutputMatchForCommand(executedMarker: IMarker | undefined, endMarker: IMarker | undefined, buffer: IBuffer, cols: number, outputMatcher: { lineMatcher: string | RegExp; anchor?: 'top' | 'bottom'; offset?: number; length?: number } | undefined): RegExpMatchArray | undefined {
if (!executedMarker || !endMarker) {
return undefined;
}
const startLine = executedMarker.line;
const endLine = endMarker.line;
if (startLine === endLine) {
return undefined;
}
if (outputMatcher?.length && (endLine - startLine) < outputMatcher.length) {
return undefined;
}
let output = '';
let line: string | undefined;
if (outputMatcher?.anchor === 'bottom') {
for (let i = endLine - (outputMatcher.offset || 0); i >= startLine; i--) {
line = getXtermLineContent(buffer, i, i, cols);
output = line + output;
const match = output.match(outputMatcher.lineMatcher);
if (match) {
return match;
}
}
} else {
for (let i = startLine + (outputMatcher?.offset || 0); i < endLine; i++) {
line = getXtermLineContent(buffer, i, i, cols);
output += line;
if (outputMatcher) {
const match = output.match(outputMatcher.lineMatcher);
if (match) {
return match;
}
}
}
}
return undefined;
}
function getXtermLineContent(buffer: IBuffer, lineStart: number, lineEnd: number, cols: number): string {
// Cap the maximum number of lines generated to prevent potential performance problems. This is
// more of a sanity check as the wrapped line should already be trimmed down at this point.
const maxLineLength = Math.max(2048 / cols * 2);
lineEnd = Math.min(lineEnd, lineStart + maxLineLength);
let content = '';
for (let i = lineStart; i <= lineEnd; i++) {
// Make sure only 0 to cols are considered as resizing when windows mode is enabled will
// retain buffer data outside of the terminal width as reflow is disabled.
const line = buffer.getLine(i);
if (line) {
content += line.translateToString(true, 0, cols);
}
}
return content;
}
@@ -331,6 +331,7 @@ export interface IPtyService extends IPtyHostController {
reduceConnectionGraceTime(): Promise<void>;
requestDetachInstance(workspaceId: string, instanceId: number): Promise<IProcessDetails | undefined>;
acceptDetachInstanceReply(requestId: number, persistentProcessId?: number): Promise<void>;
freePortKillProcess?(id: number, port: string): Promise<{ port: string; processId: string }>;
/**
* Serializes and returns terminal state.
* @param ids The persistent terminal IDs to serialize.
@@ -649,6 +650,11 @@ export interface ITerminalChildProcess {
*/
detach?(forcePersist?: boolean): Promise<void>;
/**
* Frees the port and kills the process
*/
freePortKillProcess?(port: string): Promise<{ port: string; processId: string }>;
/**
* Shutdown the terminal process.
*
@@ -316,6 +316,13 @@ export class PtyHostService extends Disposable implements IPtyService {
return this._proxy.acceptDetachInstanceReply(requestId, persistentProcessId);
}
async freePortKillProcess(id: number, port: string): Promise<{ port: string; processId: string }> {
if (!this._proxy.freePortKillProcess) {
throw new Error('freePortKillProcess does not exist on the pty proxy');
}
return this._proxy.freePortKillProcess(id, port);
}
async serializeTerminalState(ids: number[]): Promise<string> {
return this._proxy.serializeTerminalState(ids);
}
+61 -1
View File
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { execFile } from 'child_process';
import { execFile, exec } from 'child_process';
import { AutoOpenBarrier, ProcessTimeRunOnceScheduler, Promises, Queue } from 'vs/base/common/async';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, toDisposable } from 'vs/base/common/lifecycle';
@@ -103,6 +103,66 @@ export class PtyService extends Disposable implements IPtyService {
this._detachInstanceRequestStore.acceptReply(requestId, processDetails);
}
async freePortKillProcess(id: number, port: string): Promise<{ port: string; processId: string }> {
let result: { port: string; processId: string } | undefined;
if (!isWindows) {
const stdout = await new Promise<string>((resolve, reject) => {
exec(`lsof -nP -iTCP -sTCP:LISTEN | grep ${port}`, {}, (err, stdout) => {
if (err) {
return reject('Problem occurred when listing active processes');
}
resolve(stdout);
});
});
const processesForPort = stdout.split('\n');
if (processesForPort.length >= 1) {
const capturePid = /\s+(\d+)\s+/;
const processId = processesForPort[0].match(capturePid)?.[1];
if (processId) {
await new Promise<string>((resolve, reject) => {
exec(`kill ${processId}`, {}, (err, stdout) => {
if (err) {
return reject(`Problem occurred when killing the process w ID: ${processId}`);
}
resolve(stdout);
});
result = { port, processId };
});
}
}
} else {
const stdout = await new Promise<string>((resolve, reject) => {
exec(`netstat -ano | findstr "${port}"`, {}, (err, stdout) => {
if (err) {
return reject('Problem occurred when listing active processes');
}
resolve(stdout);
});
});
const processesForPort = stdout.split('\n');
if (processesForPort.length >= 1) {
const capturePid = /LISTENING\s+(\d{3})/;
const processId = processesForPort[0].match(capturePid)?.[1];
if (processId) {
await new Promise<string>((resolve, reject) => {
exec(`Taskkill /F /PID ${processId}`, {}, (err, stdout) => {
if (err) {
return reject(`Problem occurred when killing the process w ID: ${processId}`);
}
resolve(stdout);
});
result = { port, processId };
});
}
}
}
if (result) {
return result;
}
throw new Error(`Processes for port ${port} were not found`);
}
async serializeTerminalState(ids: number[]): Promise<string> {
const promises: Promise<ISerializedTerminalState>[] = [];
for (const [persistentProcessId, persistentProcess] of this._ptys.entries()) {
@@ -147,6 +147,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel<
case '$refreshProperty': return this._ptyService.refreshProperty.apply(this._ptyService, args);
case '$requestDetachInstance': return this._ptyService.requestDetachInstance(args[0], args[1]);
case '$acceptDetachedInstance': return this._ptyService.acceptDetachInstanceReply(args[0], args[1]);
case '$freePortKillProcess': return this._ptyService.freePortKillProcess?.apply(args[0], args[1]);
}
throw new Error(`IPC Command ${command} not found`);
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { IViewportRange, IBufferRange, IBufferLine, IBuffer, IBufferCellPosition } from 'xterm';
import type { IViewportRange, IBufferRange, IBufferLine, IBufferCellPosition, IBuffer } from 'xterm';
import { IRange } from 'vs/editor/common/core/range';
import { OperatingSystem } from 'vs/base/common/platform';
import { IPath, posix, win32 } from 'vs/base/common/path';
@@ -138,6 +138,7 @@ export function getXtermLineContent(buffer: IBuffer, lineStart: number, lineEnd:
return content;
}
export function positionIsInRange(position: IBufferCellPosition, range: IBufferRange): boolean {
if (position.y < range.start.y || position.y > range.end.y) {
return false;
@@ -107,6 +107,13 @@ export class RemotePty extends Disposable implements ITerminalChildProcess {
});
}
freePortKillProcess(port: string): Promise<{ port: string; processId: string }> {
if (!this._remoteTerminalChannel.freePortKillProcess) {
throw new Error('freePortKillProcess does not exist on the local pty service');
}
return this._remoteTerminalChannel.freePortKillProcess(this.id, port);
}
acknowledgeDataEvent(charCount: number): void {
// Support flow control for server spawned processes
if (this._inReplay) {
@@ -242,10 +242,6 @@ registerSendSequenceKeybinding(String.fromCharCode('A'.charCodeAt(0) - 64), {
registerSendSequenceKeybinding(String.fromCharCode('E'.charCodeAt(0) - 64), {
mac: { primary: KeyMod.CtrlCmd | KeyCode.RightArrow }
});
// Break: ctrl+C
registerSendSequenceKeybinding(String.fromCharCode('C'.charCodeAt(0) - 64), {
mac: { primary: KeyMod.CtrlCmd | KeyCode.Period }
});
// NUL: ctrl+shift+2
registerSendSequenceKeybinding('\u0000', {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit2,
@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Orientation } from 'vs/base/browser/ui/splitview/splitview';
import { IAction } from 'vs/base/common/actions';
import { Event } from 'vs/base/common/event';
import { IDisposable } from 'vs/base/common/lifecycle';
import { OperatingSystem } from 'vs/base/common/platform';
@@ -17,6 +18,7 @@ import { EditorInput } from 'vs/workbench/common/editor/editorInput';
import { IEditableData } from 'vs/workbench/common/views';
import { TerminalFindWidget } from 'vs/workbench/contrib/terminal/browser/terminalFindWidget';
import { ITerminalStatusList } from 'vs/workbench/contrib/terminal/browser/terminalStatusList';
import { IContextualAction } from 'vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon';
import { INavigationMode, IRegisterContributedProfileArgs, IRemoteTerminalAttachTarget, IStartExtensionTerminalRequest, ITerminalBackend, ITerminalConfigHelper, ITerminalFont, ITerminalProcessExtHostProxy } from 'vs/workbench/contrib/terminal/common/terminal';
import { EditorGroupColumn } from 'vs/workbench/services/editor/common/editorGroupColumn';
import { IMarker } from 'xterm';
@@ -456,6 +458,8 @@ export interface ITerminalInstance {
readonly statusList: ITerminalStatusList;
contextualActions: IContextualAction | undefined;
readonly findWidget: TerminalFindWidget;
/**
@@ -903,6 +907,36 @@ export interface ITerminalInstance {
* Activates the most recent link of the given type.
*/
openRecentLink(type: 'localFile' | 'url'): Promise<void>;
/**
* Registers contextual action listeners
*/
registerContextualActions(...options: ITerminalContextualActionOptions[]): void;
freePortKillProcess(port: string): Promise<void>;
}
export interface ITerminalContextualActionOptions {
actionName: string | DynamicActionName;
commandLineMatcher: string | RegExp;
outputMatcher?: ITerminalOutputMatcher;
getActions: ContextualActionCallback;
exitCode?: number;
}
export type ContextualMatchResult = { commandLineMatch: RegExpMatchArray; outputMatch?: RegExpMatchArray | null };
export type DynamicActionName = (matchResult: ContextualMatchResult) => string;
export type ContextualActionCallback = (matchResult: ContextualMatchResult, command: ITerminalCommand) => ICommandAction[] | undefined;
export interface ICommandAction extends IAction {
commandToRunInTerminal?: string;
addNewLine?: boolean;
}
export interface ITerminalOutputMatcher {
lineMatcher: string | RegExp;
anchor?: 'top' | 'bottom';
offset?: number;
length?: number;
}
export interface IXtermTerminal {
@@ -143,6 +143,26 @@ export function registerTerminalActions() {
}
});
registerAction2(class extends Action2 {
constructor() {
super({
id: TerminalCommandId.QuickFix,
title: { value: localize('workbench.action.terminal.quickFix', "Quick Fix"), original: 'Quick Fix' },
f1: true,
category,
precondition: TerminalContextKeys.processSupported,
keybinding: {
primary: KeyMod.CtrlCmd | KeyCode.Period,
when: TerminalContextKeys.focus,
weight: KeybindingWeight.WorkbenchContrib
},
});
}
async run(accessor: ServicesAccessor) {
accessor.get(ITerminalService).activeInstance?.contextualActions?.showQuickFixMenu();
}
});
// Register new with profile command
refreshTerminalActions([]);
@@ -350,7 +370,7 @@ export function registerTerminalActions() {
return;
}
const output = command.getOutput();
if (output) {
if (output && typeof output === 'string') {
await accessor.get(IClipboardService).writeText(output);
}
}
@@ -0,0 +1,117 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IAction } from 'vs/base/common/actions';
import { isWindows } from 'vs/base/common/platform';
import { localize } from 'vs/nls';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ContextualMatchResult, ICommandAction, ITerminalContextualActionOptions, ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalCommand } from 'vs/workbench/contrib/terminal/common/terminal';
export const GitCommandLineRegex = /git/;
export const GitPushCommandLineRegex = /git\s+push/;
export const AnyCommandLineRegex = /.{4,}/;
export const GitSimilarOutputRegex = /most similar command is\s+([^\s]{3,})/;
export const FreePortOutputRegex = /address already in use \d\.\d\.\d\.\d:(\d\d\d\d)\s+|Unable to bind [^ ]*:(\d+)|can't listen on port (\d+)|listen EADDRINUSE [^ ]*:(\d+)/;
export const GitPushOutputRegex = /git push --set-upstream origin ([^\s]+)\s+/;
export const GitCreatePrOutputRegex = /Create a pull request for \'([^\s]+)\' on GitHub by visiting:\s+remote:\s+(https:.+pull.+)\s+/;
export function gitSimilarCommand(): ITerminalContextualActionOptions {
return {
commandLineMatcher: GitCommandLineRegex,
outputMatcher: { lineMatcher: GitSimilarOutputRegex, anchor: 'bottom' },
actionName: (matchResult: ContextualMatchResult) => matchResult.outputMatch ? `Run git ${matchResult.outputMatch[1]}` : ``,
exitCode: 1,
getActions: (matchResult: ContextualMatchResult, command: ITerminalCommand) => {
const actions: ICommandAction[] = [];
const fixedCommand = matchResult?.outputMatch?.[1];
if (!fixedCommand) {
return;
}
const label = localize("terminal.gitSimilarCommand", "Run git {0}", fixedCommand);
actions.push({
class: undefined, tooltip: label, id: 'terminal.gitSimilarCommand', label, enabled: true,
commandToRunInTerminal: `git ${fixedCommand}`,
addNewLine: true,
run: () => { }
});
return actions;
}
};
}
export function freePort(terminalInstance?: Partial<ITerminalInstance>): ITerminalContextualActionOptions {
return {
actionName: (matchResult: ContextualMatchResult) => matchResult.outputMatch ? `Free port ${matchResult.outputMatch[1]}` : '',
commandLineMatcher: AnyCommandLineRegex,
outputMatcher: !isWindows ? { lineMatcher: FreePortOutputRegex, anchor: 'bottom' } : undefined,
getActions: (matchResult: ContextualMatchResult, command: ITerminalCommand) => {
const port = matchResult?.outputMatch?.[1];
if (!port) {
return;
}
const actions: ICommandAction[] = [];
const label = localize("terminal.freePort", "Free port {0}", port);
actions.push({
class: undefined, tooltip: label, id: 'terminal.freePort', label, enabled: true,
run: async () => {
await terminalInstance?.freePortKillProcess?.(port);
},
commandToRunInTerminal: command.command,
addNewLine: false
});
return actions;
}
};
}
export function gitPushSetUpstream(): ITerminalContextualActionOptions {
return {
actionName: (matchResult: ContextualMatchResult) => matchResult.outputMatch ? `Git push ${matchResult.outputMatch[1]}` : '',
commandLineMatcher: GitPushCommandLineRegex,
outputMatcher: { lineMatcher: GitPushOutputRegex, anchor: 'bottom' },
exitCode: 128,
getActions: (matchResult: ContextualMatchResult, command: ITerminalCommand) => {
const branch = matchResult?.outputMatch?.[1];
if (!branch) {
return;
}
const actions: ICommandAction[] = [];
const label = localize("terminal.gitPush", "Git push {0}", branch);
command.command = `git push --set-upstream origin ${branch}`;
actions.push({
class: undefined, tooltip: label, id: 'terminal.gitPush', label, enabled: true,
commandToRunInTerminal: command.command,
addNewLine: true,
run: () => { }
});
return actions;
}
};
}
export function gitCreatePr(openerService: IOpenerService): ITerminalContextualActionOptions {
return {
actionName: (matchResult: ContextualMatchResult) => matchResult.outputMatch ? `Create PR for ${matchResult.outputMatch[1]}` : '',
commandLineMatcher: GitPushCommandLineRegex,
outputMatcher: { lineMatcher: GitCreatePrOutputRegex, anchor: 'bottom' },
exitCode: 0,
getActions: (matchResult: ContextualMatchResult, command?: ITerminalCommand) => {
if (!command) {
return;
}
const branch = matchResult?.outputMatch?.[1];
const link = matchResult?.outputMatch?.[2];
if (!branch || !link) {
return;
}
const actions: IAction[] = [];
const label = localize("terminal.gitCreatePr", "Create PR");
actions.push({
class: undefined, tooltip: label, id: 'terminal.gitCreatePr', label, enabled: true,
run: () => openerService.open(link)
});
return actions;
}
};
}
@@ -59,8 +59,9 @@ import { AudioCue, IAudioCueService } from 'vs/workbench/contrib/audioCues/brows
import { TaskSettingId } from 'vs/workbench/contrib/tasks/common/tasks';
import { IDetectedLinks, TerminalLinkManager } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager';
import { TerminalLinkQuickpick } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkQuickpick';
import { IRequestAddInstanceToGroupEvent, ITerminalExternalLinkProvider, ITerminalInstance, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal';
import { IRequestAddInstanceToGroupEvent, ITerminalContextualActionOptions, ITerminalExternalLinkProvider, ITerminalInstance, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal';
import { TerminalLaunchHelpAction } from 'vs/workbench/contrib/terminal/browser/terminalActions';
import { gitSimilarCommand, gitCreatePr, gitPushSetUpstream, freePort } from 'vs/workbench/contrib/terminal/browser/terminalBaseContextualActions';
import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper';
import { TerminalEditorInput } from 'vs/workbench/contrib/terminal/browser/terminalEditorInput';
import { TerminalFindWidget } from 'vs/workbench/contrib/terminal/browser/terminalFindWidget';
@@ -72,6 +73,7 @@ import { TypeAheadAddon } from 'vs/workbench/contrib/terminal/browser/terminalTy
import { getTerminalResourcesFromDragEvent, getTerminalUri } from 'vs/workbench/contrib/terminal/browser/terminalUri';
import { EnvironmentVariableInfoWidget } from 'vs/workbench/contrib/terminal/browser/widgets/environmentVariableInfoWidget';
import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager';
import { ContextualActionAddon, IContextualAction } from 'vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon';
import { LineDataEventAddon } from 'vs/workbench/contrib/terminal/browser/xterm/lineDataEventAddon';
import { NavigationModeAddon } from 'vs/workbench/contrib/terminal/browser/xterm/navigationModeAddon';
import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal';
@@ -138,10 +140,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
private readonly _scopedInstantiationService: IInstantiationService;
private readonly _processManager: ITerminalProcessManager;
readonly _processManager: ITerminalProcessManager;
private readonly _resource: URI;
private _shutdownPersistentProcessId: number | undefined;
protected get processManager() { return this._processManager; }
// Enables disposal of the xterm onKey
// event when the CwdDetection capability
// is added
@@ -205,10 +207,17 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
private _target?: TerminalLocation | undefined;
private _disableShellIntegrationReporting: boolean | undefined;
private _usedShellIntegrationInjection: boolean = false;
private _contextualActionAddon: ContextualActionAddon | undefined;
readonly capabilities = new TerminalCapabilityStoreMultiplexer();
readonly statusList: ITerminalStatusList;
/**
* Enables opening the contextual actions, if any, that are available
* and registering of command finished listeners
*/
get contextualActions(): IContextualAction | undefined { return this._contextualActionAddon; }
readonly findWidget: TerminalFindWidget;
xterm?: XtermTerminal;
@@ -450,7 +459,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
this._scopedInstantiationService.invokeFunction(getDirectoryHistory)?.add(e, { remoteAuthority: this.remoteAuthority });
});
} else if (e === TerminalCapability.CommandDetection) {
this.capabilities.get(TerminalCapability.CommandDetection)?.onCommandFinished(e => {
const commandCapability = this.capabilities.get(TerminalCapability.CommandDetection);
commandCapability?.onCommandFinished(e => {
if (e.command.trim().length > 0) {
this._scopedInstantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._shellType });
}
@@ -590,6 +600,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
return undefined;
}
registerContextualActions(...actionOptions: ITerminalContextualActionOptions[]): void {
for (const actionOption of actionOptions) {
this.contextualActions?.registerCommandFinishedListener(actionOption);
}
}
private _initDimensions(): void {
// The terminal panel needs to have been created to get the real view dimensions
if (!this._container) {
@@ -702,6 +718,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
const xterm = this._scopedInstantiationService.createInstance(XtermTerminal, Terminal, this._configHelper, this._cols, this._rows, this.target || TerminalLocation.Panel, this.capabilities, this.disableShellIntegrationReporting);
this.xterm = xterm;
this._contextualActionAddon = this._scopedInstantiationService.createInstance(ContextualActionAddon, this.capabilities);
this.xterm?.raw.loadAddon(this._contextualActionAddon);
this.registerContextualActions(gitSimilarCommand(), gitCreatePr(this._openerService), gitPushSetUpstream(), freePort(this));
this._register(this._contextualActionAddon.onDidRequestRerunCommand((e) => this.sendText(e.command, e.addNewLine || false)));
const lineDataEventAddon = new LineDataEventAddon();
this.xterm.raw.loadAddon(lineDataEventAddon);
this.updateAccessibilitySupport();
@@ -709,7 +729,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
if (e.copyAsHtml) {
this.copySelection(true, e.command);
} else {
this.sendText(e.command.command, true);
this.sendText(e.command.command, e.noNewLine ? false : true);
}
});
// Write initial text, deferring onLineFeed listener when applicable to avoid firing
@@ -1483,6 +1503,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
this.xterm?.markTracker.scrollToClosestMarker(startMarkId, endMarkId, highlight);
}
public async freePortKillProcess(port: string): Promise<void> {
await this._processManager?.freePortKillProcess(port);
}
private _onProcessData(ev: IProcessDataEvent): void {
const messageId = ++this._latestXtermWriteData;
if (ev.trackCommit) {
@@ -36,6 +36,8 @@ import { IHistoryService } from 'vs/workbench/services/history/common/history';
import { IPathService } from 'vs/workbench/services/path/common/pathService';
import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService';
import { TaskSettingId } from 'vs/workbench/contrib/tasks/common/tasks';
import Severity from 'vs/base/common/severity';
import { INotificationService } from 'vs/platform/notification/common/notification';
/** The amount of time to consider terminal errors to be related to the launch */
const LAUNCHING_DURATION = 500;
@@ -136,7 +138,8 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
@ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService,
@ITelemetryService private readonly _telemetryService: ITelemetryService
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@INotificationService private readonly _notificationService: INotificationService
) {
super();
@@ -171,6 +174,17 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
}
}
async freePortKillProcess(port: string): Promise<void> {
try {
if (this._process?.freePortKillProcess) {
const result = await this._process?.freePortKillProcess(port);
this._notificationService.notify({ message: `Killed process w ID: ${result.processId} to free port ${result.port}`, severity: Severity.Info });
}
} catch (e) {
this._notificationService.notify({ message: `Could not kill process for port ${port} wth error ${e}`, severity: Severity.Warning });
}
}
override dispose(immediate: boolean = false): void {
this._isDisposed = true;
if (this._process) {
@@ -0,0 +1,167 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { ITerminalCapabilityStore, ITerminalCommand, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
// Importing types is safe in any layer
// eslint-disable-next-line local/code-import-patterns
import type { ITerminalAddon } from 'xterm-headless';
import * as dom from 'vs/base/browser/dom';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { ICommandAction, ITerminalContextualActionOptions } from 'vs/workbench/contrib/terminal/browser/terminal';
import { DecorationSelector, updateLayout } from 'vs/workbench/contrib/terminal/browser/xterm/decorationStyles';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { Terminal } from 'xterm';
import { IAction } from 'vs/base/common/actions';
export interface IContextualAction {
/**
* Shows the quick fix menu
*/
showQuickFixMenu(): void;
/**
* Registers a listener on onCommandFinished scoped to a particular command or regular
* expression and provides a callback to be executed for commands that match.
*/
registerCommandFinishedListener(options: ITerminalContextualActionOptions): void;
}
export interface IContextualActionAdddon extends IContextualAction {
onDidRequestRerunCommand: Event<{ command: string; addNewLine?: boolean }>;
}
export class ContextualActionAddon extends Disposable implements ITerminalAddon, IContextualActionAdddon {
private readonly _onDidRequestRerunCommand = new Emitter<{ command: string; addNewLine?: boolean }>();
readonly onDidRequestRerunCommand = this._onDidRequestRerunCommand.event;
private _terminal: Terminal | undefined;
private _currentQuickFixElement: HTMLElement | undefined;
private _decorationMarkerIds = new Set<number>();
private _commandListeners: Map<string, ITerminalContextualActionOptions[]> = new Map();
private _matchActions: ICommandAction[] | undefined;
constructor(private readonly _capabilities: ITerminalCapabilityStore,
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
@IConfigurationService private readonly _configurationService: IConfigurationService) {
super();
const commandDetectionCapability = this._capabilities.get(TerminalCapability.CommandDetection);
if (commandDetectionCapability) {
this._registerCommandFinishedHandler();
} else {
this._capabilities.onDidAddCapability(c => {
if (c === TerminalCapability.CommandDetection) {
this._registerCommandFinishedHandler();
}
});
}
}
activate(terminal: Terminal): void {
this._terminal = terminal;
}
showQuickFixMenu(): void {
this._currentQuickFixElement?.click();
}
registerCommandFinishedListener(options: ITerminalContextualActionOptions): void {
const matcherKey = options.commandLineMatcher.toString();
const currentOptions = this._commandListeners.get(matcherKey) || [];
currentOptions.push(options);
this._commandListeners.set(matcherKey, currentOptions);
}
private _registerCommandFinishedHandler(): void {
const terminal = this._terminal;
const commandDetection = this._capabilities.get(TerminalCapability.CommandDetection);
if (!terminal || !commandDetection) {
return;
}
this._register(commandDetection.onCommandFinished(async command => {
this._matchActions = getMatchActions(command, this._commandListeners, this._onDidRequestRerunCommand);
}));
// The buffer is not ready by the time command finish
// is called. Add the decoration on command start using the actions, if any,
// from the last command
this._register(commandDetection.onCommandStarted(() => {
if (this._matchActions) {
this._registerContextualDecoration();
this._matchActions = undefined;
}
}));
}
private _registerContextualDecoration(): void {
if (!this._terminal) {
return;
}
const marker = this._terminal.registerMarker();
if (!marker) {
return;
}
const actions = this._matchActions;
const decoration = this._terminal.registerDecoration({ marker, layer: 'top' });
decoration?.onRender((e: HTMLElement) => {
if (!this._decorationMarkerIds.has(decoration.marker.id)) {
this._currentQuickFixElement = e;
e.classList.add(DecorationSelector.QuickFix, DecorationSelector.Codicon, DecorationSelector.CommandDecoration, DecorationSelector.XtermDecoration);
e.style.color = '#ffcc00';
updateLayout(this._configurationService, e);
if (actions) {
this._decorationMarkerIds.add(decoration.marker.id);
dom.addDisposableListener(e, dom.EventType.CLICK, () => {
this._contextMenuService.showContextMenu({ getAnchor: () => e, getActions: () => actions });
this._contextMenuService.onDidHideContextMenu(() => decoration.dispose());
});
}
}
});
}
}
export function getMatchActions(command: ITerminalCommand, actionOptions: Map<string, ITerminalContextualActionOptions[]>, onDidRequestRerunCommand?: Emitter<{ command: string; addNewLine?: boolean }>): IAction[] | undefined {
const matchActions: IAction[] = [];
const newCommand = command.command;
for (const options of actionOptions.values()) {
for (const actionOption of options) {
if (actionOption.exitCode !== undefined && command.exitCode !== actionOption.exitCode) {
continue;
}
const commandLineMatch = newCommand.match(actionOption.commandLineMatcher);
if (!commandLineMatch) {
continue;
}
const outputMatcher = actionOption.outputMatcher;
let outputMatch;
if (outputMatcher) {
outputMatch = command.getOutputMatch(outputMatcher);
}
const actions = actionOption.getActions({ commandLineMatch, outputMatch }, command);
if (!actions) {
return matchActions.length === 0 ? undefined : matchActions;
}
for (const a of actions) {
matchActions.push({
id: a.id,
label: a.label,
class: a.class,
enabled: a.enabled,
run: async () => {
await a.run();
if (a.commandToRunInTerminal) {
onDidRequestRerunCommand?.fire({ command: a.commandToRunInTerminal, addNewLine: a.addNewLine });
}
},
tooltip: a.tooltip
});
}
}
}
return matchActions.length === 0 ? undefined : matchActions;
}
@@ -28,22 +28,7 @@ import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/commo
import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { terminalDecorationError, terminalDecorationIncomplete, terminalDecorationMark, terminalDecorationSuccess } from 'vs/workbench/contrib/terminal/browser/terminalIcons';
import { TaskSettingId } from 'vs/workbench/contrib/tasks/common/tasks';
const enum DecorationSelector {
CommandDecoration = 'terminal-command-decoration',
Hide = 'hide',
ErrorColor = 'error',
DefaultColor = 'default-color',
Default = 'default',
Codicon = 'codicon',
XtermDecoration = 'xterm-decoration',
OverviewRuler = '.xterm-decoration-overview-ruler'
}
const enum DecorationStyles {
DefaultDimension = 16,
MarginLeft = -17,
}
import { DecorationSelector, updateLayout } from 'vs/workbench/contrib/terminal/browser/xterm/decorationStyles';
interface IDisposableDecoration { decoration: IDecoration; disposables: IDisposable[]; exitCode?: number; markProperties?: IMarkProperties }
@@ -170,9 +155,9 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
}
public refreshLayouts(): void {
this._updateLayout(this._placeholderDecoration?.element);
updateLayout(this._configurationService, this._placeholderDecoration?.element);
for (const decoration of this._decorations) {
this._updateLayout(decoration[1].decoration.element);
updateLayout(this._configurationService, decoration[1].decoration.element);
}
}
@@ -313,7 +298,7 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
}
if (!element.classList.contains(DecorationSelector.Codicon) || command?.marker?.line === 0) {
// first render or buffer was cleared
this._updateLayout(element);
updateLayout(this._configurationService, element);
this._updateClasses(element, command?.exitCode, command?.markProperties || markProperties);
}
});
@@ -329,23 +314,6 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
return [this._createContextMenu(element, command), ...this._createHover(element, command)];
}
private _updateLayout(element?: HTMLElement): void {
if (!element) {
return;
}
const fontSize = this._configurationService.inspect(TerminalSettingId.FontSize).value;
const defaultFontSize = this._configurationService.inspect(TerminalSettingId.FontSize).defaultValue;
const lineHeight = this._configurationService.inspect(TerminalSettingId.LineHeight).value;
if (typeof fontSize === 'number' && typeof defaultFontSize === 'number' && typeof lineHeight === 'number') {
const scalar = (fontSize / defaultFontSize) <= 1 ? (fontSize / defaultFontSize) : 1;
// must be inlined to override the inlined styles from xterm
element.style.width = `${scalar * DecorationStyles.DefaultDimension}px`;
element.style.height = `${scalar * DecorationStyles.DefaultDimension * lineHeight}px`;
element.style.fontSize = `${scalar * DecorationStyles.DefaultDimension}px`;
element.style.marginLeft = `${scalar * DecorationStyles.MarginLeft}px`;
}
}
private _updateClasses(element?: HTMLElement, exitCode?: number, markProperties?: IMarkProperties): void {
if (!element) {
return;
@@ -444,7 +412,12 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
const labelText = localize("terminal.copyOutput", 'Copy Output');
actions.push({
class: undefined, tooltip: labelText, id: 'terminal.copyOutput', label: labelText, enabled: true,
run: () => this._clipboardService.writeText(command.getOutput()!)
run: () => {
const text = command.getOutput();
if (typeof text === 'string') {
this._clipboardService.writeText(text);
}
}
});
const labelHtml = localize("terminal.copyOutputAsHtml", 'Copy Output as HTML');
actions.push({
@@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* 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 { TerminalSettingId } from 'vs/platform/terminal/common/terminal';
const enum DecorationStyles {
DefaultDimension = 16,
MarginLeft = -17,
}
export const enum DecorationSelector {
CommandDecoration = 'terminal-command-decoration',
Hide = 'hide',
ErrorColor = 'error',
DefaultColor = 'default-color',
Default = 'default',
Codicon = 'codicon',
XtermDecoration = 'xterm-decoration',
OverviewRuler = '.xterm-decoration-overview-ruler',
QuickFix = 'codicon-light-bulb'
}
export function updateLayout(configurationService: IConfigurationService, element?: HTMLElement): void {
if (!element) {
return;
}
const fontSize = configurationService.inspect(TerminalSettingId.FontSize).value;
const defaultFontSize = configurationService.inspect(TerminalSettingId.FontSize).defaultValue;
const lineHeight = configurationService.inspect(TerminalSettingId.LineHeight).value;
if (typeof fontSize === 'number' && typeof defaultFontSize === 'number' && typeof lineHeight === 'number') {
const scalar = (fontSize / defaultFontSize) <= 1 ? (fontSize / defaultFontSize) : 1;
// must be inlined to override the inlined styles from xterm
element.style.width = `${scalar * DecorationStyles.DefaultDimension}px`;
element.style.height = `${scalar * DecorationStyles.DefaultDimension * lineHeight}px`;
element.style.fontSize = `${scalar * DecorationStyles.DefaultDimension}px`;
element.style.marginLeft = `${scalar * DecorationStyles.MarginLeft}px`;
}
}
@@ -90,6 +90,7 @@ export class XtermTerminal extends DisposableStore implements IXtermTerminal, II
// Always on addons
private _markNavigationAddon: MarkNavigationAddon;
private _shellIntegrationAddon: ShellIntegrationAddon;
private _decorationAddon: DecorationAddon;
// Optional addons
@@ -102,8 +103,10 @@ export class XtermTerminal extends DisposableStore implements IXtermTerminal, II
private _lastFindResult: { resultIndex: number; resultCount: number } | undefined;
get findResult(): { resultIndex: number; resultCount: number } | undefined { return this._lastFindResult; }
private readonly _onDidRequestRunCommand = new Emitter<{ command: ITerminalCommand; copyAsHtml?: boolean }>();
private readonly _onDidRequestRunCommand = new Emitter<{ command: ITerminalCommand; copyAsHtml?: boolean; noNewLine?: boolean }>();
readonly onDidRequestRunCommand = this._onDidRequestRunCommand.event;
private readonly _onDidRequestFreePort = new Emitter<string>();
readonly onDidRequestFreePort = this._onDidRequestFreePort.event;
private readonly _onDidChangeFindResults = new Emitter<{ resultIndex: number; resultCount: number } | undefined>();
readonly onDidChangeFindResults = this._onDidChangeFindResults.event;
private readonly _onDidChangeSelection = new Emitter<void>();
@@ -58,7 +58,6 @@ export interface ICreateTerminalProcessResult {
}
export class RemoteTerminalChannelClient implements IPtyHostController {
get onPtyHostExit(): Event<number> {
return this._channel.listen<number>('$onPtyHostExitEvent');
}
@@ -238,7 +237,9 @@ export class RemoteTerminalChannelClient implements IPtyHostController {
sendCommandResult(reqId: number, isError: boolean, payload: any): Promise<void> {
return this._channel.call('$sendCommandResult', [reqId, isError, payload]);
}
freePortKillProcess(id: number, port: string): Promise<{ port: string; processId: string }> {
return this._channel.call('$freePortKillProcess', [id, port]);
}
installAutoReply(match: string, reply: string): Promise<void> {
return this._channel.call('$installAutoReply', [match, reply]);
}
@@ -96,6 +96,13 @@ export interface IShellLaunchConfigResolveOptions {
allowAutomationShell?: boolean;
}
export interface ITerminalOutputMatcher {
lineMatcher: string | RegExp;
anchor?: 'top' | 'bottom';
offset?: number;
length?: number;
}
export interface ITerminalBackend {
readonly remoteAuthority: string | undefined;
@@ -347,6 +354,7 @@ export interface ITerminalCommand {
markProperties?: IMarkProperties;
hasOutput(): boolean;
getOutput(): string | undefined;
getOutputMatch(outputMatcher: ITerminalOutputMatcher): RegExpMatchArray | undefined;
}
export interface INavigationMode {
@@ -413,6 +421,7 @@ export interface ITerminalProcessManager extends IDisposable {
refreshProperty<T extends ProcessPropertyType>(type: T): Promise<IProcessPropertyMap[T]>;
updateProperty<T extends ProcessPropertyType>(property: T, value: IProcessPropertyMap[T]): void;
getBackendOS(): Promise<OperatingSystem>;
freePortKillProcess(port: string): void;
}
export const enum ProcessState {
@@ -510,7 +519,7 @@ export const enum TerminalCommandId {
ResizePaneLeft = 'workbench.action.terminal.resizePaneLeft',
ResizePaneRight = 'workbench.action.terminal.resizePaneRight',
ResizePaneUp = 'workbench.action.terminal.resizePaneUp',
CreateWithProfileButton = 'workbench.action.terminal.createProfileButton',
CreateWithProfileButton = 'workbench.action.terminal.gitCreateProfileButton',
SizeToContentWidth = 'workbench.action.terminal.sizeToContentWidth',
SizeToContentWidthInstance = 'workbench.action.terminal.sizeToContentWidthInstance',
ResizePaneDown = 'workbench.action.terminal.resizePaneDown',
@@ -552,6 +561,7 @@ export const enum TerminalCommandId {
SelectToNextLine = 'workbench.action.terminal.selectToNextLine',
ToggleEscapeSequenceLogging = 'toggleEscapeSequenceLogging',
SendSequence = 'workbench.action.terminal.sendSequence',
QuickFix = 'workbench.action.terminal.quickFix',
ToggleFindRegex = 'workbench.action.terminal.toggleFindRegex',
ToggleFindWholeWord = 'workbench.action.terminal.toggleFindWholeWord',
ToggleFindCaseSensitive = 'workbench.action.terminal.toggleFindCaseSensitive',
@@ -76,6 +76,12 @@ export class LocalPty extends Disposable implements ITerminalChildProcess {
}
this._localPtyService.resize(this.id, cols, rows);
}
freePortKillProcess(port: string): Promise<{ port: string; processId: string }> {
if (!this._localPtyService.freePortKillProcess) {
throw new Error('freePortKillProcess does not exist on the local pty service');
}
return this._localPtyService.freePortKillProcess(this.id, port);
}
async getInitialCwd(): Promise<string> {
return this._properties.initialCwd;
}
@@ -7,8 +7,11 @@ import { deepStrictEqual, ok } from 'assert';
import { timeout } from 'vs/base/common/async';
import { Terminal } from 'xterm';
import { CommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/commandDetectionCapability';
import { NullLogService } from 'vs/platform/log/common/log';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { ITerminalCommand } from 'vs/platform/terminal/common/capabilities/capabilities';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { IContextMenuDelegate } from 'vs/base/browser/contextmenu';
async function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
@@ -64,7 +67,10 @@ suite('CommandDetectionCapability', () => {
setup(() => {
xterm = new Terminal({ allowProposedApi: true, cols: 80 });
capability = new TestCommandDetectionCapability(xterm, new NullLogService());
const instantiationService = new TestInstantiationService();
instantiationService.stub(IContextMenuService, { showContextMenu(delegate: IContextMenuDelegate): void { } } as Partial<IContextMenuService>);
instantiationService.stub(ILogService, new NullLogService());
capability = instantiationService.createInstance(TestCommandDetectionCapability, xterm);
addEvents = [];
capability.onCommandFinished(e => addEvents.push(e));
assertCommands([]);
@@ -0,0 +1,231 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { strictEqual } from 'assert';
import { OpenerService } from 'vs/editor/browser/services/openerService';
import { ContextMenuService } from 'vs/platform/contextview/browser/contextMenuService';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ITerminalCommand, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
import { CommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/commandDetectionCapability';
import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/terminalCapabilityStore';
import { ICommandAction, ITerminalInstance, ITerminalOutputMatcher } from 'vs/workbench/contrib/terminal/browser/terminal';
import { freePort, FreePortOutputRegex, gitCreatePr, GitCreatePrOutputRegex, GitPushOutputRegex, gitPushSetUpstream, gitSimilarCommand, GitSimilarOutputRegex } from 'vs/workbench/contrib/terminal/browser/terminalBaseContextualActions';
import { ContextualActionAddon, getMatchActions } from 'vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon';
import { Terminal } from 'xterm';
suite('ContextualActionAddon', () => {
let contextualActionAddon: ContextualActionAddon;
let terminalInstance: Pick<ITerminalInstance, 'freePortKillProcess'>;
let commandDetection: CommandDetectionCapability;
let openerService: OpenerService;
setup(() => {
const instantiationService = new TestInstantiationService();
const xterm = new Terminal({
allowProposedApi: true,
cols: 80,
rows: 30
});
const capabilities = new TerminalCapabilityStore();
instantiationService.stub(ILogService, new NullLogService());
commandDetection = instantiationService.createInstance(CommandDetectionCapability, xterm);
capabilities.add(TerminalCapability.CommandDetection, commandDetection);
instantiationService.stub(IContextMenuService, instantiationService.createInstance(ContextMenuService));
openerService = instantiationService.createInstance(OpenerService);
instantiationService.stub(IOpenerService, openerService);
terminalInstance = {
async freePortKillProcess(port: string): Promise<void> { }
} as Pick<ITerminalInstance, 'freePortKillProcess'>;
contextualActionAddon = instantiationService.createInstance(ContextualActionAddon, capabilities);
xterm.loadAddon(contextualActionAddon);
});
suite('registerCommandFinishedListener & getMatchActions', () => {
suite('gitSimilarCommand', async () => {
const expectedMap = new Map();
const command = `git sttatus`;
const output = `git: 'sttatus' is not a git command. See 'git --help'.
The most similar command is
status`;
const exitCode = 1;
const actions = [
{
id: 'terminal.gitSimilarCommand',
label: 'Run git status',
run: true,
tooltip: 'Run git status',
enabled: true
}
];
setup(() => {
const command = gitSimilarCommand();
expectedMap.set(command.commandLineMatcher.toString(), [command]);
contextualActionAddon.registerCommandFinishedListener(command);
});
suite('returns undefined when', () => {
test('output does not match', () => {
strictEqual(getMatchActions(createCommand(command, `invalid output`, GitSimilarOutputRegex, exitCode), expectedMap), undefined);
});
test('command does not match', () => {
strictEqual(getMatchActions(createCommand(`gt sttatus`, output, GitSimilarOutputRegex, exitCode), expectedMap), undefined);
});
test('exit code does not match', () => {
strictEqual(getMatchActions(createCommand(command, output, GitSimilarOutputRegex, 2), expectedMap), undefined);
});
});
test('returns actions', () => {
assertMatchOptions(getMatchActions(createCommand(command, output, GitSimilarOutputRegex, exitCode), expectedMap), actions);
});
});
suite('freePort', () => {
const expected = new Map();
const portCommand = `yarn start dev`;
const output = `yarn run v1.22.17
warning ../../package.json: No license field
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
at Server.setupListenHandle [as _listen2] (node:net:1315:16)
at listenInCluster (node:net:1363:12)
at doListen (node:net:1501:7)
at processTicksAndRejections (node:internal/process/task_queues:84:21)
Emitted 'error' event on WebSocketServer instance at:
at Server.emit (node:events:394:28)
at emitErrorNT (node:net:1342:8)
at processTicksAndRejections (node:internal/process/task_queues:83:21) {
}
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.`;
const actionOptions = [{
id: 'terminal.freePort',
label: 'Free port 3000',
run: true,
tooltip: 'Free port 3000',
enabled: true
}];
setup(() => {
const command = freePort(terminalInstance);
expected.set(command.commandLineMatcher.toString(), [command]);
contextualActionAddon.registerCommandFinishedListener(command);
});
suite('returns undefined when', () => {
test('output does not match', () => {
strictEqual(getMatchActions(createCommand(portCommand, `invalid output`, FreePortOutputRegex), expected), undefined);
});
});
test('returns actions', () => {
assertMatchOptions(getMatchActions(createCommand(portCommand, output, FreePortOutputRegex), expected), actionOptions);
});
});
suite('gitPushSetUpstream', () => {
const expectedMap = new Map();
const command = `git push`;
const output = `fatal: The current branch test22 has no upstream branch.
To push the current branch and set the remote as upstream, use
git push --set-upstream origin test22 `;
const exitCode = 128;
const actions = [
{
id: 'terminal.gitPush',
label: 'Git push test22',
run: true,
tooltip: 'Git push test22',
enabled: true
}
];
setup(() => {
const command = gitPushSetUpstream();
expectedMap.set(command.commandLineMatcher.toString(), [command]);
contextualActionAddon.registerCommandFinishedListener(command);
});
suite('returns undefined when', () => {
test('output does not match', () => {
strictEqual(getMatchActions(createCommand(command, `invalid output`, GitPushOutputRegex, exitCode), expectedMap), undefined);
});
test('command does not match', () => {
strictEqual(getMatchActions(createCommand(`git status`, output, GitPushOutputRegex, exitCode), expectedMap), undefined);
});
test('exit code does not match', () => {
strictEqual(getMatchActions(createCommand(command, output, GitPushOutputRegex, 2), expectedMap), undefined);
});
});
test('returns actions', () => {
assertMatchOptions(getMatchActions(createCommand(command, output, GitPushOutputRegex, exitCode), expectedMap), actions);
});
});
suite('gitCreatePr', () => {
const expectedMap = new Map();
const command = `git push`;
const output = `Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
remote:
remote: Create a pull request for 'test22' on GitHub by visiting:
remote: https://github.com/meganrogge/xterm.js/pull/new/test22
remote:
To https://github.com/meganrogge/xterm.js
* [new branch] test22 -> test22
Branch 'test22' set up to track remote branch 'test22' from 'origin'. `;
const exitCode = 0;
const actions = [
{
id: 'terminal.gitCreatePr',
label: 'Create PR',
run: true,
tooltip: 'Create PR',
enabled: true
}
];
setup(() => {
const command = gitCreatePr(openerService);
expectedMap.set(command.commandLineMatcher.toString(), [command]);
contextualActionAddon.registerCommandFinishedListener(command);
});
suite('returns undefined when', () => {
test('output does not match', () => {
strictEqual(getMatchActions(createCommand(command, `invalid output`, GitCreatePrOutputRegex, exitCode), expectedMap), undefined);
});
test('command does not match', () => {
strictEqual(getMatchActions(createCommand(`git status`, output, GitCreatePrOutputRegex, exitCode), expectedMap), undefined);
});
test('exit code does not match', () => {
strictEqual(getMatchActions(createCommand(command, output, GitCreatePrOutputRegex, 2), expectedMap), undefined);
});
});
test('returns actions', () => {
assertMatchOptions(getMatchActions(createCommand(command, output, GitCreatePrOutputRegex, exitCode), expectedMap), actions);
});
});
});
});
function createCommand(command: string, output: string, outputMatcher?: RegExp | string, exitCode?: number): ITerminalCommand {
return {
command,
exitCode,
getOutput: () => { return output; },
getOutputMatch: (matcher: ITerminalOutputMatcher) => {
if (outputMatcher) {
return output.match(outputMatcher) ?? undefined;
}
return undefined;
},
timestamp: Date.now(),
hasOutput: () => !!output
};
}
function assertMatchOptions(actual: ICommandAction[] | undefined, expected: { id: string; label: string; run: boolean; tooltip: string; enabled: boolean }[]): void {
strictEqual(actual?.length, expected.length);
let index = 0;
for (const i of actual) {
const j = expected[index];
strictEqual(i.id, j.id, `ID`);
strictEqual(i.enabled, j.enabled, `enabled`);
strictEqual(i.label, j.label, `label`);
strictEqual(!!i.run, j.run, `run`);
strictEqual(i.tooltip, j.tooltip, `tooltip`);
index++;
}
}
@@ -25,6 +25,7 @@ import { TestContextService } from 'vs/workbench/test/common/workbenchTestServic
import { Terminal } from 'xterm';
import { IFileQuery, ISearchComplete, ISearchService } from 'vs/workbench/services/search/common/search';
import { SearchService } from 'vs/workbench/services/search/common/searchService';
import { ITerminalOutputMatcher } from 'vs/workbench/contrib/terminal/common/terminal';
export interface ITerminalLinkActivationResult {
source: 'editor' | 'search';
@@ -131,6 +132,7 @@ suite('Workbench - TerminalLinkOpeners', () => {
cwd: '/initial/cwd',
timestamp: 0,
getOutput() { return undefined; },
getOutputMatch(outputMatcher: ITerminalOutputMatcher) { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any,
@@ -267,6 +269,7 @@ suite('Workbench - TerminalLinkOpeners', () => {
cwd,
timestamp: 0,
getOutput() { return undefined; },
getOutputMatch(outputMatcher: ITerminalOutputMatcher) { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any,
@@ -351,6 +354,7 @@ suite('Workbench - TerminalLinkOpeners', () => {
cwd,
timestamp: 0,
getOutput() { return undefined; },
getOutputMatch(outputMatcher: ITerminalOutputMatcher) { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any,
@@ -21,7 +21,7 @@ import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilitie
import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
import { Schemas } from 'vs/base/common/network';
function createInstance(partial?: Partial<ITerminalInstance>): Pick<ITerminalInstance, 'shellLaunchConfig' | 'userHome' | 'cwd' | 'initialCwd' | 'processName' | 'sequence' | 'workspaceFolder' | 'staticTitle' | 'capabilities' | 'title' | 'description'> {
export function createInstance(partial?: Partial<ITerminalInstance>): Pick<ITerminalInstance, 'shellLaunchConfig' | 'userHome' | 'cwd' | 'initialCwd' | 'processName' | 'sequence' | 'workspaceFolder' | 'staticTitle' | 'capabilities' | 'title' | 'description'> {
const capabilities = new TerminalCapabilityStore();
if (!isWindows) {
capabilities.add(TerminalCapability.NaiveCwdDetection, null!);
@@ -58,7 +58,7 @@ suite('DecorationAddon', () => {
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(IContextMenuService, instantiationService.createInstance(ContextMenuService));
const capabilities = new TerminalCapabilityStore();
capabilities.add(TerminalCapability.CommandDetection, new CommandDetectionCapability(xterm, new NullLogService()));
capabilities.add(TerminalCapability.CommandDetection, instantiationService.createInstance(CommandDetectionCapability, xterm));
instantiationService.stub(ILifecycleService, new TestLifecycleService());
decorationAddon = instantiationService.createInstance(DecorationAddon, capabilities);
xterm.loadAddon(decorationAddon);
@@ -45,7 +45,7 @@ suite('ShellIntegrationAddon', () => {
xterm = new Terminal({ allowProposedApi: true, cols: 80, rows: 30 });
const instantiationService = new TestInstantiationService();
instantiationService.stub(ILogService, NullLogService);
shellIntegrationAddon = instantiationService.createInstance(TestShellIntegrationAddon, undefined, undefined);
shellIntegrationAddon = instantiationService.createInstance(TestShellIntegrationAddon);
xterm.loadAddon(shellIntegrationAddon);
capabilities = shellIntegrationAddon.capabilities;
});