Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add basic contextual commands #160952

Merged
merged 48 commits into from Sep 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
531da80
start working on freeing a port
meganrogge Sep 15, 2022
ff4ebab
Merge branch 'main' into merogge/context-commands
meganrogge Sep 15, 2022
6cc6013
setup ctrl+. keybinding
meganrogge Sep 15, 2022
c3d4469
add keybinding
meganrogge Sep 15, 2022
6f426c6
refactor part 2 move code out of contextual action addon
meganrogge Sep 15, 2022
3756515
move exec to ptyService
meganrogge Sep 16, 2022
ae1ef7f
add registerCommandFinishedListener to terminal instance
meganrogge Sep 16, 2022
3ab25a3
refactor part 3
meganrogge Sep 16, 2022
f2042da
see if this fixes shell int tests
meganrogge Sep 16, 2022
0a479ac
fix failing test
meganrogge Sep 16, 2022
72b5748
tidy
meganrogge Sep 16, 2022
3f50422
more cleanup
meganrogge Sep 16, 2022
c238ef8
both actions working nicely
meganrogge Sep 16, 2022
3c23cf7
Merge branch 'main' into merogge/context-commands
meganrogge Sep 16, 2022
d0eab77
on command started, add decoration
meganrogge Sep 19, 2022
47fa72d
Merge branch 'main' into merogge/context-commands
meganrogge Sep 19, 2022
1f67500
clean up
meganrogge Sep 19, 2022
72c7d70
test
meganrogge Sep 19, 2022
594a0af
Merge branch 'main' into merogge/context-commands
meganrogge Sep 19, 2022
606818a
polish
meganrogge Sep 19, 2022
b4c5062
refactor
meganrogge Sep 19, 2022
db54195
get all 4 commands to work
meganrogge Sep 19, 2022
a36a727
add tests
meganrogge Sep 19, 2022
e3a8c2f
add tests
meganrogge Sep 19, 2022
1493154
pass optional outputMatcher to getOutput
meganrogge Sep 20, 2022
0379603
fix issues
meganrogge Sep 20, 2022
79bb77d
fix port regex
meganrogge Sep 20, 2022
a17967c
improve upon tests
meganrogge Sep 20, 2022
0cd3a9d
add all tests
meganrogge Sep 20, 2022
8061f20
clean up tests
meganrogge Sep 20, 2022
cc0b50c
Update src/vs/platform/terminal/common/capabilities/commandDetectionC…
meganrogge Sep 20, 2022
8a7c056
Update src/vs/platform/terminal/common/capabilities/commandDetectionC…
meganrogge Sep 20, 2022
ca074b4
cleanup
meganrogge Sep 20, 2022
7420e5f
more test polish
meganrogge Sep 20, 2022
c94e479
more cleanup
meganrogge Sep 20, 2022
99b5963
add run command action
meganrogge Sep 20, 2022
af5ccc0
Merge branch 'main' into merogge/context-commands
meganrogge Sep 20, 2022
f743dfe
add tests and improve regex
meganrogge Sep 21, 2022
ed070f9
better
meganrogge Sep 21, 2022
0456336
improve freePort regex and add tests
meganrogge Sep 21, 2022
b290730
Merge branch 'main' into merogge/context-commands
meganrogge Sep 21, 2022
e89206d
working for 3/4 in all line wrapping cases
meganrogge Sep 21, 2022
09f83b5
save state before reverting
meganrogge Sep 21, 2022
43d0020
revert changes
meganrogge Sep 21, 2022
73273f8
Merge branch 'main' into merogge/context-commands
meganrogge Sep 21, 2022
64281ce
account for windows
meganrogge Sep 21, 2022
3161548
use getXtermLineContent
meganrogge Sep 22, 2022
a71d1de
Merge branch 'main' into merogge/context-commands
meganrogge Sep 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -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;
}

Expand Down
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions src/vs/platform/terminal/common/terminal.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }>;
meganrogge marked this conversation as resolved.
Show resolved Hide resolved

/**
* Shutdown the terminal process.
*
Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/terminal/node/ptyHostService.ts
Expand Up @@ -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);
}
Expand Down
62 changes: 61 additions & 1 deletion src/vs/platform/terminal/node/ptyService.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down
1 change: 1 addition & 0 deletions src/vs/server/node/remoteTerminalChannel.ts
Expand Up @@ -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`);
Expand Down
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/remotePty.ts
Expand Up @@ -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) {
Expand Down
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminal.ts
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -456,6 +458,8 @@ export interface ITerminalInstance {

readonly statusList: ITerminalStatusList;

contextualActions: IContextualAction | undefined;

readonly findWidget: TerminalFindWidget;

/**
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 21 additions & 1 deletion src/vs/workbench/contrib/terminal/browser/terminalActions.ts
Expand Up @@ -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([]);

Expand Down Expand Up @@ -350,7 +370,7 @@ export function registerTerminalActions() {
return;
}
const output = command.getOutput();
if (output) {
if (output && typeof output === 'string') {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
await accessor.get(IClipboardService).writeText(output);
}
}
Expand Down