Add terminal shell integration decorations (#142538)

This commit is contained in:
Megan Rogge 2022-02-10 15:47:33 -06:00 committed by GitHub
parent ec9df1d972
commit a3b972bacb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 253 additions and 21 deletions

View file

@ -41,8 +41,7 @@ export class CommandDetectionCapability implements ICommandDetectionCapability {
constructor(
private readonly _terminal: Terminal,
@ILogService private readonly _logService: ILogService
) {
}
) { }
setCwd(value: string) {
this._cwd = value;
@ -131,19 +130,23 @@ export class CommandDetectionCapability implements ICommandDetectionCapability {
const command = this._currentCommand.command;
this._logService.debug('CommandDetectionCapability#handleCommandFinished', this._terminal.buffer.active.cursorX, this._currentCommand.commandFinishedMarker?.line, this._currentCommand.command, this._currentCommand);
this._exitCode = exitCode;
if (this._currentCommand.commandStartMarker === undefined || !this._terminal.buffer.active) {
return;
}
if (command !== undefined && !command.startsWith('\\')) {
const buffer = this._terminal.buffer.active;
const clonedPartialCommand = { ...this._currentCommand };
const timestamp = Date.now();
const newCommand = {
command,
timestamp: Date.now(),
marker: this._currentCommand.commandStartMarker,
endMarker: this._currentCommand.commandFinishedMarker,
timestamp,
cwd: this._cwd,
exitCode: this._exitCode,
getOutput: () => getOutputForCommand(clonedPartialCommand, buffer),
marker: this._currentCommand.commandStartMarker
hasOutput: (this._currentCommand.commandExecutedMarker!.line < this._currentCommand.commandFinishedMarker!.line),
getOutput: () => getOutputForCommand(clonedPartialCommand, buffer)
};
this._commands.push(newCommand);
this._logService.debug('CommandDetectionCapability#onCommandFinished', newCommand);
@ -164,6 +167,9 @@ function getOutputForCommand(command: ICurrentPartialCommand, buffer: IBuffer):
const startLine = command.commandExecutedMarker!.line;
const endLine = command.commandFinishedMarker!.line;
if (startLine === endLine) {
return undefined;
}
let output = '';
for (let i = startLine; i < endLine; i++) {
output += buffer.getLine(i)?.translateToString() + '\n';

View file

@ -412,3 +412,14 @@
.monaco-workbench .pane-body.integrated-terminal .terminal-group > .monaco-split-view2.vertical .terminal-drop-overlay.drop-after {
top: 50%;
}
.terminal-command-decoration {
transition: opacity 0.5s;
transition: all .2s ease-in-out;
margin-left: -1%;
width: 50%;
}
.terminal-command-decoration:hover {
transform: scale(2, 1);
}

View file

@ -177,3 +177,8 @@
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}

View file

@ -637,6 +637,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
const lineDataEventAddon = new LineDataEventAddon();
this.xterm.raw.loadAddon(lineDataEventAddon);
this.updateAccessibilitySupport();
this.xterm.onDidRequestRunCommand(command => this.sendText(command, true));
// Write initial text, deferring onLineFeed listener when applicable to avoid firing
// onLineData events containing initialText
if (this._shellLaunchConfig.initialText) {
@ -762,23 +763,23 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
type Item = IQuickPickItem & { command?: ITerminalCommand };
const items: Item[] = [];
if (type === 'command') {
for (const { command, timestamp, cwd, exitCode, getOutput } of commands) {
for (const entry of commands) {
// trim off any whitespace and/or line endings
const label = command.trim();
const label = entry.command.trim();
if (label.length === 0) {
continue;
}
let detail = '';
if (cwd) {
detail += `cwd: ${cwd} `;
if (entry.cwd) {
detail += `cwd: ${entry.cwd} `;
}
if (exitCode) {
if (entry.exitCode) {
// Since you cannot get the last command's exit code on pwsh, just whether it failed
// or not, -1 is treated specially as simply failed
if (exitCode === -1) {
if (entry.exitCode === -1) {
detail += 'failed';
} else {
detail += `exitCode: ${exitCode}`;
detail += `exitCode: ${entry.exitCode}`;
}
}
detail = detail.trim();
@ -790,17 +791,17 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
}];
// Merge consecutive commands
if (items.length > 0 && items[items.length - 1].label === label) {
items[items.length - 1].id = timestamp.toString();
items[items.length - 1].id = entry.timestamp.toString();
items[items.length - 1].detail = detail;
continue;
}
items.push({
label,
description: fromNow(timestamp, true),
description: fromNow(entry.timestamp, true),
detail,
id: timestamp.toString(),
command: { command, timestamp, cwd, exitCode, getOutput },
buttons
id: entry.timestamp.toString(),
command: entry,
buttons: (!entry.endMarker?.isDisposed && !entry.marker?.isDisposed && (entry.endMarker!.line - entry.marker!.line > 0)) ? buttons : undefined
});
}
} else {

View file

@ -0,0 +1,177 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle';
import { ITerminalCommand } from 'vs/workbench/contrib/terminal/common/terminal';
import { IDecoration, ITerminalAddon, Terminal } from 'xterm';
import * as dom from 'vs/base/browser/dom';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { ITerminalCapabilityStore, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { TERMINAL_COMMAND_DECORATION_DEFAULT_BACKGROUND_COLOR, TERMINAL_COMMAND_DECORATION_ERROR_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { IHoverService } from 'vs/workbench/services/hover/browser/hover';
import { IAction } from 'vs/base/common/actions';
import { Emitter } from 'vs/base/common/event';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { localize } from 'vs/nls';
import { Delayer } from 'vs/base/common/async';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { fromNow } from 'vs/base/common/date';
import { isWindows } from 'vs/base/common/platform';
const enum DecorationSelector {
CommandDecoration = 'terminal-command-decoration',
Error = 'error',
}
export class DecorationAddon extends Disposable implements ITerminalAddon {
private _decorations: IDecoration[] = [];
protected _terminal: Terminal | undefined;
private _hoverDelayer: Delayer<void>;
private _commandListener: IDisposable | undefined;
private readonly _onDidRequestRunCommand = this._register(new Emitter<string>());
readonly onDidRequestRunCommand = this._onDidRequestRunCommand.event;
constructor(
private readonly _capabilities: ITerminalCapabilityStore,
@IClipboardService private readonly _clipboardService: IClipboardService,
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
@IHoverService private readonly _hoverService: IHoverService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
) {
super();
this._register({
dispose: () => {
dispose(this._decorations);
this._commandListener?.dispose();
}
});
this._attachToCommandCapability();
this._hoverDelayer = this._register(new Delayer(this._configurationService.getValue('workbench.hover.delay')));
}
private _attachToCommandCapability(): void {
if (this._capabilities.has(TerminalCapability.CommandDetection)) {
this._addCommandListener();
} else {
this._register(this._capabilities.onDidAddCapability(c => {
if (c === TerminalCapability.CommandDetection) {
this._addCommandListener();
}
}));
}
this._register(this._capabilities.onDidRemoveCapability(c => {
if (c === TerminalCapability.CommandDetection) {
this._commandListener?.dispose();
}
}));
}
private _addCommandListener(): void {
if (this._commandListener) {
return;
}
const capability = this._capabilities.get(TerminalCapability.CommandDetection);
if (!capability) {
return;
}
this._commandListener = capability.onCommandFinished(c => {
//TODO: remove when this has been fixed in xterm.js
if (!isWindows && c.command === 'clear') {
this._terminal?.clear();
dispose(this._decorations);
return;
}
const element = this.registerCommandDecoration(c);
if (element) {
this._decorations.push(element);
}
});
}
activate(terminal: Terminal): void {
this._terminal = terminal;
}
registerCommandDecoration(command: ITerminalCommand): IDecoration | undefined {
if (!command.marker) {
throw new Error(`cannot add decoration for command: ${command}, and terminal: ${this._terminal}`);
}
if (!this._terminal || command.command.trim().length === 0) {
return undefined;
}
const decoration = this._terminal.registerDecoration({ marker: command.marker });
decoration?.onRender(target => {
this._createContextMenu(target, command);
this._createHover(target, command);
target.classList.add(DecorationSelector.CommandDecoration);
if (command.exitCode) {
target.classList.add(DecorationSelector.Error);
}
});
return decoration;
}
private _createContextMenu(target: HTMLElement, command: ITerminalCommand) {
// When the xterm Decoration gets disposed of, its element gets removed from the dom
// along with its listeners
dom.addDisposableListener(target, dom.EventType.CLICK, async () => {
const actions = await this._getCommandActions(command);
this._contextMenuService.showContextMenu({ getAnchor: () => target, getActions: () => actions });
});
}
private _createHover(target: HTMLElement, command: ITerminalCommand): void {
// When the xterm Decoration gets disposed of, its element gets removed from the dom
// along with its listeners
dom.addDisposableListener(target, dom.EventType.MOUSE_ENTER, async () => {
let hoverContent = `${localize('terminal-prompt-context-menu', "Show Actions")}` + ` ...${fromNow(command.timestamp)} `;
if (command.exitCode) {
hoverContent += `\n\n\n\nExit Code: ${command.exitCode} `;
}
const hoverOptions = { content: new MarkdownString(hoverContent), target };
await this._hoverDelayer.trigger(() => {
this._hoverService.showHover(hoverOptions);
});
});
dom.addDisposableListener(target, dom.EventType.MOUSE_LEAVE, async () => {
this._hoverService.hideHover();
});
dom.addDisposableListener(target, dom.EventType.MOUSE_OUT, async () => {
this._hoverService.hideHover();
});
dom.addDisposableListener(target.parentElement?.parentElement!, 'click', async () => {
this._hoverService.hideHover();
});
}
private async _getCommandActions(command: ITerminalCommand): Promise<IAction[]> {
const actions: IAction[] = [];
if (command.hasOutput) {
actions.push({
class: 'copy-output', tooltip: 'Copy Output', dispose: () => { }, id: 'terminal.copyOutput', label: localize("terminal.copyOutput", 'Copy Output'), enabled: true,
run: () => this._clipboardService.writeText(command.getOutput()!)
});
}
actions.push({
class: 'rerun-command', tooltip: 'Rerun Command', dispose: () => { }, id: 'terminal.rerunCommand', label: localize("terminal.rerunCommand", 'Re-run Command'), enabled: true,
run: () => this._onDidRequestRunCommand.fire(command.command)
});
return actions;
}
}
registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
const commandDecorationSuccessColor = theme.getColor(TERMINAL_COMMAND_DECORATION_DEFAULT_BACKGROUND_COLOR);
collector.addRule(`.${DecorationSelector.CommandDecoration} { background-color: ${commandDecorationSuccessColor ? commandDecorationSuccessColor.toString() : ''}; }`);
const commandDecorationErrorColor = theme.getColor(TERMINAL_COMMAND_DECORATION_ERROR_BACKGROUND_COLOR);
collector.addRule(`.${DecorationSelector.CommandDecoration}.${DecorationSelector.Error} { background-color: ${commandDecorationErrorColor ? commandDecorationErrorColor.toString() : ''}; }`);
});

View file

@ -26,11 +26,13 @@ import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeServic
import { IViewDescriptorService, ViewContainerLocation } from 'vs/workbench/common/views';
import { editorBackground } from 'vs/platform/theme/common/colorRegistry';
import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme';
import { TERMINAL_FOREGROUND_COLOR, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR, ansiColorIdentifiers } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry';
import { TERMINAL_FOREGROUND_COLOR, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, ansiColorIdentifiers, TERMINAL_SELECTION_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry';
import { Color } from 'vs/base/common/color';
import { ShellIntegrationAddon } from 'vs/workbench/contrib/terminal/browser/xterm/shellIntegrationAddon';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { DecorationAddon } from 'vs/workbench/contrib/terminal/browser/xterm/decorationAddon';
import { ITerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { Emitter } from 'vs/base/common/event';
// How long in milliseconds should an average frame take to render for a notification to appear
// which suggests the fallback DOM-based renderer
@ -62,6 +64,9 @@ export class XtermTerminal extends DisposableStore implements IXtermTerminal {
private _unicode11Addon?: Unicode11AddonType;
private _webglAddon?: WebglAddonType;
private readonly _onDidRequestRunCommand = new Emitter<string>();
readonly onDidRequestRunCommand = this._onDidRequestRunCommand.event;
get commandTracker(): ICommandTracker { return this._commandTrackerAddon; }
get shellIntegration(): IShellIntegration { return this._shellIntegrationAddon; }
@ -150,6 +155,9 @@ export class XtermTerminal extends DisposableStore implements IXtermTerminal {
this.raw.loadAddon(this._commandTrackerAddon);
this._shellIntegrationAddon = this._instantiationService.createInstance(ShellIntegrationAddon);
this.raw.loadAddon(this._shellIntegrationAddon);
const decorationAddon = this._instantiationService.createInstance(DecorationAddon, capabilities);
decorationAddon.onDidRequestRunCommand(command => this._onDidRequestRunCommand.fire(command));
this.raw.loadAddon(decorationAddon);
}
attachToElement(container: HTMLElement) {

View file

@ -108,7 +108,9 @@ export interface ITerminalCommand {
cwd?: string;
exitCode?: number;
marker?: IXtermMarker;
endMarker?: IXtermMarker;
getOutput(): string | undefined;
hasOutput: boolean;
}
/**

View file

@ -14,7 +14,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'
import { URI } from 'vs/base/common/uri';
import { IProcessDetails } from 'vs/platform/terminal/common/terminalProcess';
import { Registry } from 'vs/platform/registry/common/platform';
import { ITerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { ITerminalCapabilityStore, IXtermMarker } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
export const TERMINAL_VIEW_ID = 'terminal';
@ -330,6 +330,16 @@ export interface IShellIntegration {
capabilities: ITerminalCapabilityStore;
}
export interface ITerminalCommand {
command: string;
timestamp: number;
cwd?: string;
exitCode?: number;
marker?: IXtermMarker;
hasOutput: boolean;
getOutput(): string | undefined;
}
export interface INavigationMode {
exitNavigationMode(): void;
focusPreviousLine(): void;

View file

@ -27,6 +27,16 @@ export const TERMINAL_SELECTION_BACKGROUND_COLOR = registerColor('terminal.selec
dark: '#FFFFFF40',
hc: '#FFFFFF80'
}, nls.localize('terminal.selectionBackground', 'The selection background color of the terminal.'));
export const TERMINAL_COMMAND_DECORATION_DEFAULT_BACKGROUND_COLOR = registerColor('terminalCommandDecoration.defaultBackground', {
light: '#66afe0',
dark: '#399ee6',
hc: '#399ee6'
}, nls.localize('terminalCommandDecoration.defaultBackground', 'The default terminal command decoration background color.'));
export const TERMINAL_COMMAND_DECORATION_ERROR_BACKGROUND_COLOR = registerColor('terminalCommandDecoration.errorBackground', {
light: '#a1260d',
dark: '#be1100',
hc: '#be1100'
}, nls.localize('terminalCommandDecoration.errorBackground', 'The terminal command decoration background color when there is an exit code.'));
export const TERMINAL_BORDER_COLOR = registerColor('terminal.border', {
dark: PANEL_BORDER,
light: PANEL_BORDER,

View file

@ -121,7 +121,8 @@ suite('Workbench - TerminalLinkOpeners', () => {
getOutput() { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any
} as Partial<IXtermMarker> as any,
hasOutput: true
}]);
await opener.open({
text: 'file.txt',
@ -169,7 +170,8 @@ suite('Workbench - TerminalLinkOpeners', () => {
getOutput() { return undefined; },
marker: {
line: 0
} as Partial<IXtermMarker> as any
} as Partial<IXtermMarker> as any,
hasOutput: true
}]);
await opener.open({
text: 'file.txt',