diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 2a170e42514..772820d716f 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -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', diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index ea06c3a190b..a05ec3f927d 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -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 { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 26d2213cf67..11cbbf3e240 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -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 { - 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 = 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(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) { diff --git a/src/vs/workbench/contrib/terminal/common/history.ts b/src/vs/workbench/contrib/terminal/common/history.ts new file mode 100644 index 00000000000..0cc6b7b1d7a --- /dev/null +++ b/src/vs/workbench/contrib/terminal/common/history.ts @@ -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 { + /** + * 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 { + 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 extends Disposable implements ITerminalPersistedHistory { + private readonly _entries: LRUCache; + 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(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 | undefined { + const raw = this._storageService.get(this._getEntriesStorageKey(), StorageScope.GLOBAL); + if (raw === undefined || raw.length === 0) { + return undefined; + } + let serialized: ISerializedCache | undefined = undefined; + try { + serialized = JSON.parse(raw); + } catch { + // Invalid data + return undefined; + } + return serialized; + } + + private _saveState() { + const serialized: ISerializedCache = { 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}`; + } +} diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 02d338c46b2..70e546274ee 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -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 = ['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[] = [ diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index acc1a9d04ad..e221966b3c6 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -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 + }, } }; diff --git a/src/vs/workbench/contrib/terminal/test/common/history.test.ts b/src/vs/workbench/contrib/terminal/test/common/history.test.ts new file mode 100644 index 00000000000..6b850e10e78 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/common/history.test.ts @@ -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; + 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); + }); +});