mirror of
https://github.com/Microsoft/vscode
synced 2024-10-30 11:10:48 +00:00
Add terminal shell integration decorations (#142538)
This commit is contained in:
parent
ec9df1d972
commit
a3b972bacb
10 changed files with 253 additions and 21 deletions
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -177,3 +177,8 @@
|
|||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() : ''}; }`);
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -108,7 +108,9 @@ export interface ITerminalCommand {
|
|||
cwd?: string;
|
||||
exitCode?: number;
|
||||
marker?: IXtermMarker;
|
||||
endMarker?: IXtermMarker;
|
||||
getOutput(): string | undefined;
|
||||
hasOutput: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in a new issue