Merge pull request #143212 from microsoft/tyriar/141006

Support terminal command history persistence
This commit is contained in:
Daniel Imms 2022-02-17 12:23:19 -08:00 committed by GitHub
commit 72ef0f3977
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 391 additions and 46 deletions

View file

@ -106,10 +106,11 @@ export const enum TerminalSettingId {
ShellIntegrationShowWelcome = 'terminal.integrated.shellIntegration.showWelcome',
ShellIntegrationCommandIcon = 'terminal.integrated.shellIntegration.commandIcon',
ShellIntegrationCommandIconError = 'terminal.integrated.shellIntegration.commandIconError',
ShellIntegrationCommandIconSkipped = 'terminal.integrated.shellIntegration.commandIconSkipped'
ShellIntegrationCommandIconSkipped = 'terminal.integrated.shellIntegration.commandIconSkipped',
ShellIntegrationCommandHistory = 'terminal.integrated.shellIntegration.history'
}
export enum WindowsShellType {
export const enum WindowsShellType {
CommandPrompt = 'cmd',
PowerShell = 'pwsh',
Wsl = 'wsl',

View file

@ -51,6 +51,7 @@ import { AbstractVariableResolverService } from 'vs/workbench/services/configura
import { ITerminalQuickPickItem } from 'vs/workbench/contrib/terminal/browser/terminalProfileQuickpick';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { getIconId, getColorClass, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon';
import { getCommandHistory } from 'vs/workbench/contrib/terminal/common/history';
export const switchTerminalActionViewItemSeparator = '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500';
export const switchTerminalShowTabsTitle = localize('showTerminalTabs', "Show Tabs");
@ -2073,6 +2074,21 @@ export function registerTerminalActions() {
return getSelectedInstances(accessor)?.[0].toggleSizeToContentWidth();
}
});
registerAction2(class extends Action2 {
constructor() {
super({
id: TerminalCommandId.ClearCommandHistory,
title: { value: localize('workbench.action.terminal.clearCommandHistory', "Clear Command History"), original: 'Clear Command History' },
f1: true,
category,
precondition: ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated)
});
}
run(accessor: ServicesAccessor) {
getCommandHistory(accessor).clear();
}
});
// Some commands depend on platform features
if (BrowserFeatures.clipboard.writeText) {
registerAction2(class extends Action2 {

View file

@ -68,6 +68,7 @@ import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xterm
import { ITerminalCommand, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
import { TerminalCapabilityStoreMultiplexer } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
import { IEnvironmentVariableInfo } from 'vs/workbench/contrib/terminal/common/environmentVariable';
import { getCommandHistory } from 'vs/workbench/contrib/terminal/common/history';
import { DEFAULT_COMMANDS_TO_SKIP_SHELL, INavigationMode, ITerminalBackend, ITerminalProcessManager, ITerminalProfileResolverService, ProcessState, ShellIntegrationExitCode, TerminalCommandId, TERMINAL_CREATION_COMMANDS, TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal';
import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey';
import { formatMessageForTerminal } from 'vs/workbench/contrib/terminal/common/terminalStrings';
@ -399,11 +400,17 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
this._register(this.capabilities.onDidAddCapability(e => {
this._logService.debug('terminalInstance added capability', e);
if (e === TerminalCapability.CwdDetection) {
this.capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd((e) => {
this.capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd(e => {
this._cwd = e;
this._xtermOnKey?.dispose();
this.refreshTabLabels(this.title, TitleEventSource.Config);
});
} else if (e === TerminalCapability.CommandDetection) {
this.capabilities.get(TerminalCapability.CommandDetection)?.onCommandFinished(e => {
if (e.command.trim().length > 0) {
this._instantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._shellType });
}
});
}
}));
this._register(this.capabilities.onDidRemoveCapability(e => this._logService.debug('terminalInstance removed capability', e)));
@ -756,65 +763,104 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
}
async runRecent(type: 'command' | 'cwd'): Promise<void> {
const commands = this.capabilities.get(TerminalCapability.CommandDetection)?.commands;
if (!commands || !this.xterm) {
if (!this.xterm) {
return;
}
type Item = IQuickPickItem & { command?: ITerminalCommand };
const items: Item[] = [];
let items: (Item | IQuickPickItem | IQuickPickSeparator)[] = [];
const commandMap: Set<string> = new Set();
const removeFromCommandHistoryButton: IQuickInputButton = {
iconClass: ThemeIcon.asClassName(Codicon.close),
tooltip: nls.localize('removeCommand', "Remove from Command History")
};
if (type === 'command') {
for (const entry of commands) {
// trim off any whitespace and/or line endings
const label = entry.command.trim();
if (label.length === 0) {
continue;
}
let detail = '';
if (entry.cwd) {
detail += `cwd: ${entry.cwd} `;
}
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 (entry.exitCode === -1) {
detail += 'failed';
} else {
detail += `exitCode: ${entry.exitCode}`;
const commands = this.capabilities.get(TerminalCapability.CommandDetection)?.commands;
// Current session history
if (commands && commands.length > 0) {
for (const entry of commands) {
// trim off any whitespace and/or line endings
const label = entry.command.trim();
if (label.length === 0) {
continue;
}
let description = fromNow(entry.timestamp, true);
if (entry.cwd) {
description += ` @ ${entry.cwd}`;
}
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 (entry.exitCode === -1) {
description += ' failed';
} else {
description += ` exitCode: ${entry.exitCode}`;
}
}
description = description.trim();
const iconClass = ThemeIcon.asClassName(Codicon.output);
const buttons: IQuickInputButton[] = [{
iconClass,
tooltip: nls.localize('viewCommandOutput', "View Command Output"),
alwaysVisible: true
}];
// Merge consecutive commands
const lastItem = items.length > 0 ? items[items.length - 1] : undefined;
if (lastItem?.type !== 'separator' && lastItem?.label === label) {
lastItem.id = entry.timestamp.toString();
lastItem.description = description;
continue;
}
items.push({
label,
description,
id: entry.timestamp.toString(),
command: entry,
buttons: (!entry.endMarker?.isDisposed && !entry.marker?.isDisposed && (entry.endMarker!.line - entry.marker!.line > 0)) ? buttons : undefined
});
commandMap.add(label);
}
detail = detail.trim();
const iconClass = ThemeIcon.asClassName(Codicon.output);
const buttons: IQuickInputButton[] = [{
iconClass,
tooltip: nls.localize('viewCommandOutput', "View Command Output"),
alwaysVisible: true
}];
// Merge consecutive commands
if (items.length > 0 && items[items.length - 1].label === label) {
items[items.length - 1].id = entry.timestamp.toString();
items[items.length - 1].detail = detail;
continue;
items = items.reverse();
items.unshift({ type: 'separator', label: 'current session' });
}
// Gather previous session history
const history = this._instantiationService.invokeFunction(getCommandHistory);
const previousSessionItems: IQuickPickItem[] = [];
for (const [label, info] of history.entries) {
// Only add previous session item if it's not in this session
if (!commandMap.has(label) && info.shellType === this.shellType) {
previousSessionItems.push({
label,
buttons: [removeFromCommandHistoryButton]
});
}
items.push({
label,
description: fromNow(entry.timestamp, true),
detail,
id: entry.timestamp.toString(),
command: entry,
buttons: (!entry.endMarker?.isDisposed && !entry.marker?.isDisposed && (entry.endMarker!.line - entry.marker!.line > 0)) ? buttons : undefined
});
}
if (previousSessionItems.length > 0) {
items.push(
{ type: 'separator', label: 'previous sessions' },
...previousSessionItems
);
}
} else {
const cwds = this.capabilities.get(TerminalCapability.CwdDetection)?.cwds || [];
for (const label of cwds) {
items.push({ label });
}
items = items.reverse();
}
if (items.length === 0) {
return;
}
const outputProvider = this._instantiationService.createInstance(TerminalOutputProvider);
const quickPick = this._quickInputService.createQuickPick();
quickPick.items = items.reverse();
quickPick.items = items;
return new Promise<void>(r => {
quickPick.onDidTriggerItemButton(async e => {
if (e.button === removeFromCommandHistoryButton) {
this._instantiationService.invokeFunction(getCommandHistory)?.remove(e.item.label);
}
const selectedCommand = (e.item as Item).command;
const output = selectedCommand?.getOutput();
if (output && selectedCommand?.command) {

View file

@ -0,0 +1,174 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from 'vs/base/common/lifecycle';
import { LRUCache } from 'vs/base/common/map';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { TerminalSettingId, TerminalShellType } from 'vs/platform/terminal/common/terminal';
/**
* Tracks a list of generic entries.
*/
export interface ITerminalPersistedHistory<T> {
/**
* The persisted entries.
*/
readonly entries: IterableIterator<[string, T]>;
/**
* Adds an entry.
*/
add(key: string, value: T): void;
/**
* Removes an entry.
*/
remove(key: string): void;
/**
* Clears all entries.
*/
clear(): void;
}
interface ISerializedCache<T> {
entries: { key: string; value: T }[];
}
const enum Constants {
DefaultHistoryLimit = 100
}
const enum StorageKeys {
Entries = 'terminal.history.entries',
Timestamp = 'terminal.history.timestamp'
}
let commandHistory: ITerminalPersistedHistory<{ shellType: TerminalShellType }> | undefined = undefined;
export function getCommandHistory(accessor: ServicesAccessor): ITerminalPersistedHistory<{ shellType: TerminalShellType }> {
if (!commandHistory) {
commandHistory = accessor.get(IInstantiationService).createInstance(TerminalPersistedHistory, 'commands') as TerminalPersistedHistory<{ shellType: TerminalShellType }>;
}
return commandHistory;
}
export class TerminalPersistedHistory<T> extends Disposable implements ITerminalPersistedHistory<T> {
private readonly _entries: LRUCache<string, T>;
private _timestamp: number = 0;
private _isReady = false;
private _isStale = true;
get entries(): IterableIterator<[string, T]> {
this._ensureUpToDate();
return this._entries.entries();
}
constructor(
private readonly _storageDataKey: string,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IStorageService private readonly _storageService: IStorageService
) {
super();
// Init cache
this._entries = new LRUCache<string, T>(this._getHistoryLimit());
// Listen for config changes to set history limit
this._configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationCommandHistory)) {
this._entries.limit = this._getHistoryLimit();
}
});
// Listen to cache changes from other windows
this._storageService.onDidChangeValue(e => {
if (e.key === this._getTimestampStorageKey() && !this._isStale) {
this._isStale = this._storageService.getNumber(this._getTimestampStorageKey(), StorageScope.GLOBAL, 0) !== this._timestamp;
}
});
}
add(key: string, value: T) {
this._ensureUpToDate();
this._entries.set(key, value);
this._saveState();
}
remove(key: string) {
this._ensureUpToDate();
this._entries.delete(key);
this._saveState();
}
clear() {
this._ensureUpToDate();
this._entries.clear();
this._saveState();
}
private _ensureUpToDate() {
// Initial load
if (!this._isReady) {
this._loadState();
this._isReady = true;
}
// React to stale cache caused by another window
if (this._isStale) {
// Since state is saved whenever the entries change, it's a safe assumption that no
// merging of entries needs to happen, just loading the new state.
this._entries.clear();
this._loadState();
this._isStale = false;
}
}
private _loadState() {
this._timestamp = this._storageService.getNumber(this._getTimestampStorageKey(), StorageScope.GLOBAL, 0);
// Load global entries plus
const serialized = this._loadPersistedState();
if (serialized) {
for (const entry of serialized.entries) {
this._entries.set(entry.key, entry.value);
}
}
}
private _loadPersistedState(): ISerializedCache<T> | undefined {
const raw = this._storageService.get(this._getEntriesStorageKey(), StorageScope.GLOBAL);
if (raw === undefined || raw.length === 0) {
return undefined;
}
let serialized: ISerializedCache<T> | undefined = undefined;
try {
serialized = JSON.parse(raw);
} catch {
// Invalid data
return undefined;
}
return serialized;
}
private _saveState() {
const serialized: ISerializedCache<T> = { entries: [] };
this._entries.forEach((value, key) => serialized.entries.push({ key, value }));
this._storageService.store(this._getEntriesStorageKey(), JSON.stringify(serialized), StorageScope.GLOBAL, StorageTarget.MACHINE);
this._timestamp = Date.now();
this._storageService.store(this._getTimestampStorageKey(), this._timestamp, StorageScope.GLOBAL, StorageTarget.MACHINE);
}
private _getHistoryLimit() {
const historyLimit = this._configurationService.getValue(TerminalSettingId.ShellIntegrationCommandHistory);
return typeof historyLimit === 'number' ? historyLimit : Constants.DefaultHistoryLimit;
}
private _getTimestampStorageKey() {
return `${StorageKeys.Timestamp}.${this._storageDataKey}`;
}
private _getEntriesStorageKey() {
return `${StorageKeys.Entries}.${this._storageDataKey}`;
}
}

View file

@ -291,8 +291,8 @@ export interface ITerminalConfiguration {
ignoreProcessNames: string[];
autoReplies: { [key: string]: string };
shellIntegration: {
enabled: boolean
}
enabled: boolean;
};
}
export const DEFAULT_LOCAL_ECHO_EXCLUDE: ReadonlyArray<string> = ['vim', 'vi', 'nano', 'tmux'];
@ -559,6 +559,7 @@ export const enum TerminalCommandId {
MoveToEditorInstance = 'workbench.action.terminal.moveToEditorInstance',
MoveToTerminalPanel = 'workbench.action.terminal.moveToTerminalPanel',
SetDimensions = 'workbench.action.terminal.setDimensions',
ClearCommandHistory = 'workbench.action.terminal.clearCommandHistory',
}
export const DEFAULT_COMMANDS_TO_SKIP_SHELL: string[] = [

View file

@ -543,6 +543,12 @@ const terminalConfiguration: IConfigurationNode = {
type: 'boolean',
default: true
},
[TerminalSettingId.ShellIntegrationCommandHistory]: {
restricted: true,
markdownDescription: localize('terminal.integrated.shellIntegration.history', "Controls the number of recently used commands to keep in the terminal command history. Set to 0 to disable terminal command history."),
type: 'number',
default: 100
},
}
};

View file

@ -0,0 +1,101 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { deepStrictEqual, strictEqual } from 'assert';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { IStorageService } from 'vs/platform/storage/common/storage';
import { ITerminalPersistedHistory, TerminalPersistedHistory } from 'vs/workbench/contrib/terminal/common/history';
import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
function getConfig(limit: number) {
return {
terminal: {
integrated: {
shellIntegration: {
history: limit
}
}
}
};
}
suite('TerminalPersistedHistory', () => {
let history: ITerminalPersistedHistory<number>;
let instantiationService: TestInstantiationService;
let storageService: TestStorageService;
let configurationService: TestConfigurationService;
setup(() => {
configurationService = new TestConfigurationService(getConfig(5));
storageService = new TestStorageService();
instantiationService = new TestInstantiationService();
instantiationService.set(IConfigurationService, configurationService);
instantiationService.set(IStorageService, storageService);
history = instantiationService.createInstance(TerminalPersistedHistory, 'test');
});
test('should support adding items to the cache and respect LRU', () => {
history.add('foo', 1);
deepStrictEqual(Array.from(history.entries), [
['foo', 1]
]);
history.add('bar', 2);
deepStrictEqual(Array.from(history.entries), [
['foo', 1],
['bar', 2]
]);
history.add('foo', 1);
deepStrictEqual(Array.from(history.entries), [
['bar', 2],
['foo', 1]
]);
});
test('should support removing specific items', () => {
history.add('1', 1);
history.add('2', 2);
history.add('3', 3);
history.add('4', 4);
history.add('5', 5);
strictEqual(Array.from(history.entries).length, 5);
history.add('6', 6);
strictEqual(Array.from(history.entries).length, 5);
});
test('should limit the number of entries based on config', () => {
history.add('1', 1);
history.add('2', 2);
history.add('3', 3);
history.add('4', 4);
history.add('5', 5);
strictEqual(Array.from(history.entries).length, 5);
history.add('6', 6);
strictEqual(Array.from(history.entries).length, 5);
configurationService.setUserConfiguration('terminal', getConfig(2).terminal);
configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as any);
strictEqual(Array.from(history.entries).length, 2);
history.add('7', 7);
strictEqual(Array.from(history.entries).length, 2);
configurationService.setUserConfiguration('terminal', getConfig(3).terminal);
configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as any);
strictEqual(Array.from(history.entries).length, 2);
history.add('8', 8);
strictEqual(Array.from(history.entries).length, 3);
history.add('9', 9);
strictEqual(Array.from(history.entries).length, 3);
});
test('should reload from storage service after recreation', () => {
history.add('1', 1);
history.add('2', 2);
history.add('3', 3);
strictEqual(Array.from(history.entries).length, 3);
const history2 = instantiationService.createInstance(TerminalPersistedHistory, 'test');
strictEqual(Array.from(history2.entries).length, 3);
});
});