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

command decoration improvements #142866

Merged
merged 13 commits into from Feb 11, 2022
16 changes: 15 additions & 1 deletion src/vs/platform/terminal/common/terminal.ts
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
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
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') {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
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() : ''}; }`);
});
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
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
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
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