diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index e2e9f8bab98e3..5b00519a62d9f 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -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; } diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index 1d268538a0a56..48606b7fafaa3 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -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; +} diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 9f78d7db571ee..d45df31b65a1e 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -331,6 +331,7 @@ export interface IPtyService extends IPtyHostController { reduceConnectionGraceTime(): Promise; requestDetachInstance(workspaceId: string, instanceId: number): Promise; acceptDetachInstanceReply(requestId: number, persistentProcessId?: number): Promise; + 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; + /** + * Frees the port and kills the process + */ + freePortKillProcess?(port: string): Promise<{ port: string; processId: string }>; + /** * Shutdown the terminal process. * diff --git a/src/vs/platform/terminal/node/ptyHostService.ts b/src/vs/platform/terminal/node/ptyHostService.ts index aab32f8cfcd1b..41cb6ae3a1499 100644 --- a/src/vs/platform/terminal/node/ptyHostService.ts +++ b/src/vs/platform/terminal/node/ptyHostService.ts @@ -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 { return this._proxy.serializeTerminalState(ids); } diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 0d8f58a6dbd3b..282bf02ff66fb 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -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((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((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((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((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 { const promises: Promise[] = []; for (const [persistentProcessId, persistentProcess] of this._ptys.entries()) { diff --git a/src/vs/server/node/remoteTerminalChannel.ts b/src/vs/server/node/remoteTerminalChannel.ts index 7c8f29b1e385a..457e31a4451ef 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -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`); diff --git a/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts index bb6042ce80abb..c882247c15ddc 100644 --- a/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts +++ b/src/vs/workbench/contrib/terminal/browser/links/terminalLinkHelpers.ts @@ -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; diff --git a/src/vs/workbench/contrib/terminal/browser/remotePty.ts b/src/vs/workbench/contrib/terminal/browser/remotePty.ts index 0a32dbab6ed86..7b7ac69688f51 100644 --- a/src/vs/workbench/contrib/terminal/browser/remotePty.ts +++ b/src/vs/workbench/contrib/terminal/browser/remotePty.ts @@ -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) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index ed581516f8544..8af479229ed4f 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -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, diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index ee8797508f021..518ebf4fadb58 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -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; + + /** + * Registers contextual action listeners + */ + registerContextualActions(...options: ITerminalContextualActionOptions[]): void; + + freePortKillProcess(port: string): Promise; +} + +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 { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index bb7356e9e25c1..4966108f8a048 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -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); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalBaseContextualActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalBaseContextualActions.ts new file mode 100644 index 0000000000000..f703c13b1b501 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalBaseContextualActions.ts @@ -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): 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; + } + }; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index c59a61ec633ac..7f6efdb27e5f8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -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 { + await this._processManager?.freePortKillProcess(port); + } + private _onProcessData(ev: IProcessDataEvent): void { const messageId = ++this._latestXtermWriteData; if (ev.trackCommit) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index c1c0ddf9f501d..08da73cc57b49 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -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 { + 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) { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon.ts new file mode 100644 index 0000000000000..01426d8e0f725 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/xterm/contextualActionAddon.ts @@ -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(); + + private _commandListeners: Map = 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, 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; +} diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts index c86855c4138f4..e520738108ac9 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts @@ -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({ diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/decorationStyles.ts b/src/vs/workbench/contrib/terminal/browser/xterm/decorationStyles.ts new file mode 100644 index 0000000000000..5f3a59ec230a5 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/xterm/decorationStyles.ts @@ -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`; + } +} + diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 943d30d0fa03e..892e5411f86af 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -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(); + 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(); diff --git a/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts index 377973047c3f5..23d2d4552b1ec 100644 --- a/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts @@ -58,7 +58,6 @@ export interface ICreateTerminalProcessResult { } export class RemoteTerminalChannelClient implements IPtyHostController { - get onPtyHostExit(): Event { return this._channel.listen('$onPtyHostExitEvent'); } @@ -238,7 +237,9 @@ export class RemoteTerminalChannelClient implements IPtyHostController { sendCommandResult(reqId: number, isError: boolean, payload: any): Promise { 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 { return this._channel.call('$installAutoReply', [match, reply]); } diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index a93e196bf1a0d..e886f17e41821 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -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(type: T): Promise; updateProperty(property: T, value: IProcessPropertyMap[T]): void; getBackendOS(): Promise; + 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', diff --git a/src/vs/workbench/contrib/terminal/electron-sandbox/localPty.ts b/src/vs/workbench/contrib/terminal/electron-sandbox/localPty.ts index 3556ce4b6be1d..dea8aba884eea 100644 --- a/src/vs/workbench/contrib/terminal/electron-sandbox/localPty.ts +++ b/src/vs/workbench/contrib/terminal/electron-sandbox/localPty.ts @@ -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 { return this._properties.initialCwd; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts index 00dfbccf93773..1a9e2ab90b2f0 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts @@ -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 { return new Promise((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); + instantiationService.stub(ILogService, new NullLogService()); + capability = instantiationService.createInstance(TestCommandDetectionCapability, xterm); addEvents = []; capability.onCommandFinished(e => addEvents.push(e)); assertCommands([]); diff --git a/src/vs/workbench/contrib/terminal/test/browser/contextualActionAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/contextualActionAddon.test.ts new file mode 100644 index 0000000000000..aff3ed79918d3 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/contextualActionAddon.test.ts @@ -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; + 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 { } + } as Pick; + 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++; + } +} diff --git a/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkOpeners.test.ts b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkOpeners.test.ts index 54d0d6d4c74d7..4aaa1f4fedfae 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkOpeners.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/links/terminalLinkOpeners.test.ts @@ -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 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 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 as any, diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 9d89281d10d85..1169424ac82f1 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -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): Pick { +export function createInstance(partial?: Partial): Pick { const capabilities = new TerminalCapabilityStore(); if (!isWindows) { capabilities.add(TerminalCapability.NaiveCwdDetection, null!); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts index cf3ddc47e58a5..335b810d5b175 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts @@ -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); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.test.ts index 5e3442d462c37..f30df87be7751 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.test.ts @@ -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; });