Skip to content

Commit

Permalink
command decoration improvements (#142866)
Browse files Browse the repository at this point in the history
  • Loading branch information
meganrogge committed Feb 11, 2022
1 parent d8eb600 commit 56e686e
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 59 deletions.
16 changes: 15 additions & 1 deletion src/vs/platform/terminal/common/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export const enum TerminalSettingId {
IgnoreProcessNames = 'terminal.integrated.ignoreProcessNames',
AutoReplies = 'terminal.integrated.autoReplies',
EnableShellIntegration = 'terminal.integrated.enableShellIntegration',
ShowShellIntegrationWelcome = 'terminal.integrated.showShellIntegrationWelcome'
ShowShellIntegrationWelcome = 'terminal.integrated.showShellIntegrationWelcome',
CommandIcon = 'terminal.integrated.commandIcon',
CommandIconError = 'terminal.integrated.commandIconError'
}

export enum WindowsShellType {
Expand Down Expand Up @@ -522,6 +524,18 @@ export const enum TerminalLocationString {
Editor = 'editor'
}

export const enum TerminalCommandIcon {
TriangleRight = 'triangle-right',
ChevronRight = 'chevron-right',
}

export const enum TerminalCommandIconError {
TriangleRight = 'triangle-right',
ChevronRight = 'chevron-right',
X = 'x'
}


export type TerminalIcon = ThemeIcon | URI | { light: URI; dark: URI };

export interface IShellLaunchConfigDto {
Expand Down
5 changes: 2 additions & 3 deletions src/vs/workbench/contrib/terminal/browser/media/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,6 @@
top: 50%;
}

.terminal-command-decoration {
margin-left: -1%;
width: 50%;
.terminal-command-decoration:hover {
cursor: pointer;
}
118 changes: 64 additions & 54 deletions src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,25 @@ 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';
import { toolbarHoverBackground } from 'vs/platform/theme/common/colorRegistry';
import { TerminalSettingId } from 'vs/platform/terminal/common/terminal';

const enum DecorationSelector {
CommandDecoration = 'terminal-command-decoration',
Error = 'error',
ErrorColor = 'error',
Codicon = 'codicon',
}

const enum DecorationStyles { ButtonMargin = 4 }

interface IDisposableDecoration { decoration: IDecoration; diposables: IDisposable[] }

export class DecorationAddon extends Disposable implements ITerminalAddon {
private _decorations: IDecoration[] = [];
protected _terminal: Terminal | undefined;
private _hoverDelayer: Delayer<void>;
private _commandListener: IDisposable | undefined;
private _contextMenuVisible: boolean = false;
private _decorations: Map<number, IDisposableDecoration> = new Map();

private readonly _onDidRequestRunCommand = this._register(new Emitter<string>());
readonly onDidRequestRunCommand = this._onDidRequestRunCommand.event;
Expand All @@ -46,14 +53,24 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
super();
this._register({
dispose: () => {
dispose(this._decorations);
this._commandListener?.dispose();
this._clearDecorations();
}
});
this._attachToCommandCapability();
this._register(this._contextMenuService.onDidShowContextMenu(() => this._contextMenuVisible = true));
this._register(this._contextMenuService.onDidHideContextMenu(() => this._contextMenuVisible = false));
this._hoverDelayer = this._register(new Delayer(this._configurationService.getValue('workbench.hover.delay')));
}

private _clearDecorations(): void {
for (const [, decorationDisposables] of Object.entries(this._decorations)) {
decorationDisposables.decoration.dispose();
dispose(decorationDisposables.disposables);
}
this._decorations.clear();
}

private _attachToCommandCapability(): void {
if (this._capabilities.has(TerminalCapability.CommandDetection)) {
this._addCommandListener();
Expand All @@ -79,78 +96,69 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {
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);
}
});
this._commandListener = capability.onCommandFinished(c => this.registerCommandDecoration(c));
}

activate(terminal: Terminal): void {
this._terminal = terminal;
}
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}`);
throw new Error(`cannot add a decoration for a command ${JSON.stringify(command)} with no marker`);
}
if (!this._terminal || command.command.trim().length === 0) {
return undefined;
}

const decoration = this._terminal.registerDecoration({ marker: command.marker });
if (!decoration) {
return undefined;
}

decoration?.onRender(target => {
this._createContextMenu(target, command);
this._createHover(target, command);

target.classList.add(DecorationSelector.CommandDecoration);
if (command.exitCode) {
target.classList.add(DecorationSelector.Error);
decoration.onRender(target => {
if (decoration.element && !this._decorations.get(decoration.marker.id)) {
this._decorations.set(decoration.marker.id, { decoration, diposables: [this._createContextMenu(decoration.element, command), ...this._createHover(decoration.element, command)] });
}
if (decoration.element?.clientWidth! > 0) {
const marginWidth = ((decoration.element?.parentElement?.parentElement?.previousElementSibling?.clientWidth || 0) - (decoration.element?.parentElement?.parentElement?.clientWidth || 0)) * .5;
target.style.marginLeft = `${((marginWidth - (decoration.element!.clientWidth + DecorationStyles.ButtonMargin)) * .5) - marginWidth}px`;
target.classList.add(DecorationSelector.CommandDecoration);
target.classList.add(DecorationSelector.Codicon);
if (command.exitCode) {
target.classList.add(DecorationSelector.ErrorColor);
target.classList.add(`codicon-${this._configurationService.getValue(TerminalSettingId.CommandIconError)}`);
} else {
target.classList.add(`codicon-${this._configurationService.getValue(TerminalSettingId.CommandIcon)}`);
}
target.style.width = `${marginWidth}px`;
target.style.height = `${marginWidth}px`;
}
});

return decoration;
}

private _createContextMenu(target: HTMLElement, command: ITerminalCommand) {
private _createContextMenu(target: HTMLElement, command: ITerminalCommand): IDisposable {
// 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 () => {
return 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 _createHover(target: HTMLElement, command: ITerminalCommand): IDisposable[] {
return [
dom.addDisposableListener(target, dom.EventType.MOUSE_ENTER, async () => {
if (this._contextMenuVisible) {
return;
}
let hoverContent = `${localize('terminal-prompt-context-menu', "Show Actions")}` + ` ...${fromNow(command.timestamp, true)}`;
if (command.exitCode) {
hoverContent += `\n\n\n\nExit Code: ${command.exitCode} `;
}
await this._hoverDelayer.trigger(() => { this._hoverService.showHover({ content: new MarkdownString(hoverContent), target }); });
}),
dom.addDisposableListener(target, dom.EventType.MOUSE_LEAVE, () => this._hoverService.hideHover()),
dom.addDisposableListener(target, dom.EventType.MOUSE_OUT, () => this._hoverService.hideHover())];
}

private async _getCommandActions(command: ITerminalCommand): Promise<IAction[]> {
Expand All @@ -171,7 +179,9 @@ export class DecorationAddon extends Disposable implements ITerminalAddon {

registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
const commandDecorationDefaultColor = theme.getColor(TERMINAL_COMMAND_DECORATION_DEFAULT_BACKGROUND_COLOR);
collector.addRule(`.${DecorationSelector.CommandDecoration} { background-color: ${commandDecorationDefaultColor ? commandDecorationDefaultColor.toString() : ''}; }`);
collector.addRule(`.${DecorationSelector.CommandDecoration} { color: ${commandDecorationDefaultColor ? commandDecorationDefaultColor.toString() : ''}; } `);
const commandDecorationErrorColor = theme.getColor(TERMINAL_COMMAND_DECORATION_ERROR_BACKGROUND_COLOR);
collector.addRule(`.${DecorationSelector.CommandDecoration}.${DecorationSelector.Error} { background-color: ${commandDecorationErrorColor ? commandDecorationErrorColor.toString() : ''}; }`);
collector.addRule(`.${DecorationSelector.CommandDecoration}.${DecorationSelector.ErrorColor} { color: ${commandDecorationErrorColor ? commandDecorationErrorColor.toString() : ''}; } `);
const toolbarHoverBackgroundColor = theme.getColor(toolbarHoverBackground);
collector.addRule(`.${DecorationSelector.CommandDecoration}:hover { background-color: ${toolbarHoverBackgroundColor ? toolbarHoverBackgroundColor.toString() : ''}; }`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { localize } from 'vs/nls';
import { DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, TerminalCursorStyle, DEFAULT_COMMANDS_TO_SKIP_SHELL, SUGGESTIONS_FONT_WEIGHT, MINIMUM_FONT_WEIGHT, MAXIMUM_FONT_WEIGHT, DEFAULT_LOCAL_ECHO_EXCLUDE } from 'vs/workbench/contrib/terminal/common/terminal';
import { TerminalLocationString, TerminalSettingId } from 'vs/platform/terminal/common/terminal';
import { TerminalCommandIcon, TerminalCommandIconError, TerminalLocationString, TerminalSettingId } from 'vs/platform/terminal/common/terminal';
import { isMacintosh, isWindows } from 'vs/base/common/platform';
import { Registry } from 'vs/platform/registry/common/platform';

Expand Down Expand Up @@ -103,6 +103,27 @@ const terminalConfiguration: IConfigurationNode = {
default: 'view',
description: localize('terminal.integrated.defaultLocation', "Controls where newly created terminals will appear.")
},
[TerminalSettingId.CommandIcon]: {
type: 'string',
enum: [TerminalCommandIcon.ChevronRight, TerminalCommandIcon.TriangleRight],
enumDescriptions: [
localize('terminal.integrated.commandIcon.chevronRight', "A chevron pointed to the right"),
localize('terminal.integrated.commandIcon.triangleRight', "A triangle pointed to the right")
],
default: 'triangle-right',
description: localize('terminal.integrated.commandIcon', "Controls the icon that will be used for each command in terminals with shell integration enabled that do not have an associated exit code.")
},
[TerminalSettingId.CommandIconError]: {
type: 'string',
enum: [TerminalCommandIconError.ChevronRight, TerminalCommandIconError.TriangleRight, TerminalCommandIconError.X],
enumDescriptions: [
localize('terminal.integrated.commandIconError.chevronRight', "A chevron pointed to the right"),
localize('terminal.integrated.commandIconError.triangleRight', "A triangle pointed to the right"),
localize('terminal.integrated.commandIconError.x', "An X"),
],
default: 'x',
description: localize('terminal.integrated.commandIconError', "Controls the icon that will be used for each command in terminals with shell integration enabled that do have an associated exit code.")
},
[TerminalSettingId.TabsFocusMode]: {
type: 'string',
enum: ['singleClick', 'doubleClick'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { equals } from 'vs/base/common/arrays';
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
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 { IStorageService } from 'vs/platform/storage/common/storage';
Expand Down Expand Up @@ -71,6 +73,7 @@ suite('TerminalLinkManager', () => {
viewDescriptorService = new TestViewDescriptorService();

instantiationService = new TestInstantiationService();
instantiationService.stub(IContextMenuService, instantiationService.createInstance(ContextMenuService));
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IStorageService, new TestStorageService());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { TestConfigurationService } from 'vs/platform/configuration/test/common/
import { IDecoration, IDecorationOptions, Terminal } from 'xterm';
import { TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { CommandDetectionCapability } from 'vs/workbench/contrib/terminal/browser/capabilities/commandDetectionCapability';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { ContextMenuService } from 'vs/platform/contextview/browser/contextMenuService';

class TestTerminal extends Terminal {
override registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
Expand Down Expand Up @@ -41,6 +43,7 @@ suite('DecorationAddon', () => {
rows: 30
});
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(IContextMenuService, instantiationService.createInstance(ContextMenuService));
const capabilities = new TerminalCapabilityStore();
capabilities.add(TerminalCapability.CommandDetection, new CommandDetectionCapability(xterm, new NullLogService()));
decorationAddon = instantiationService.createInstance(DecorationAddon, capabilities);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServic
import { isSafari } from 'vs/base/browser/browser';
import { TerminalLocation } from 'vs/platform/terminal/common/terminal';
import { TerminalCapabilityStore } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { ContextMenuService } from 'vs/platform/contextview/browser/contextMenuService';

class TestWebglAddon {
static shouldThrow = false;
Expand Down Expand Up @@ -108,6 +110,7 @@ suite('XtermTerminal', () => {
instantiationService.stub(IStorageService, new TestStorageService());
instantiationService.stub(IThemeService, themeService);
instantiationService.stub(IViewDescriptorService, viewDescriptorService);
instantiationService.stub(IContextMenuService, instantiationService.createInstance(ContextMenuService));

configHelper = instantiationService.createInstance(TerminalConfigHelper);
xterm = instantiationService.createInstance(TestXtermTerminal, Terminal, configHelper, 80, 30, TerminalLocation.Panel, new TerminalCapabilityStore());
Expand Down

0 comments on commit 56e686e

Please sign in to comment.