Skip to content

Commit 72ef0f3

Browse files
authored
Merge pull request #143212 from microsoft/tyriar/141006
Support terminal command history persistence
2 parents 8437d74 + 7de1a81 commit 72ef0f3

File tree

7 files changed

+391
-46
lines changed

7 files changed

+391
-46
lines changed

src/vs/platform/terminal/common/terminal.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ export const enum TerminalSettingId {
106106
ShellIntegrationShowWelcome = 'terminal.integrated.shellIntegration.showWelcome',
107107
ShellIntegrationCommandIcon = 'terminal.integrated.shellIntegration.commandIcon',
108108
ShellIntegrationCommandIconError = 'terminal.integrated.shellIntegration.commandIconError',
109-
ShellIntegrationCommandIconSkipped = 'terminal.integrated.shellIntegration.commandIconSkipped'
109+
ShellIntegrationCommandIconSkipped = 'terminal.integrated.shellIntegration.commandIconSkipped',
110+
ShellIntegrationCommandHistory = 'terminal.integrated.shellIntegration.history'
110111
}
111112

112-
export enum WindowsShellType {
113+
export const enum WindowsShellType {
113114
CommandPrompt = 'cmd',
114115
PowerShell = 'pwsh',
115116
Wsl = 'wsl',

src/vs/workbench/contrib/terminal/browser/terminalActions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { AbstractVariableResolverService } from 'vs/workbench/services/configura
5151
import { ITerminalQuickPickItem } from 'vs/workbench/contrib/terminal/browser/terminalProfileQuickpick';
5252
import { IThemeService } from 'vs/platform/theme/common/themeService';
5353
import { getIconId, getColorClass, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon';
54+
import { getCommandHistory } from 'vs/workbench/contrib/terminal/common/history';
5455

5556
export const switchTerminalActionViewItemSeparator = '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500';
5657
export const switchTerminalShowTabsTitle = localize('showTerminalTabs', "Show Tabs");
@@ -2073,6 +2074,21 @@ export function registerTerminalActions() {
20732074
return getSelectedInstances(accessor)?.[0].toggleSizeToContentWidth();
20742075
}
20752076
});
2077+
registerAction2(class extends Action2 {
2078+
constructor() {
2079+
super({
2080+
id: TerminalCommandId.ClearCommandHistory,
2081+
title: { value: localize('workbench.action.terminal.clearCommandHistory', "Clear Command History"), original: 'Clear Command History' },
2082+
f1: true,
2083+
category,
2084+
precondition: ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated)
2085+
});
2086+
}
2087+
run(accessor: ServicesAccessor) {
2088+
getCommandHistory(accessor).clear();
2089+
}
2090+
});
2091+
20762092
// Some commands depend on platform features
20772093
if (BrowserFeatures.clipboard.writeText) {
20782094
registerAction2(class extends Action2 {

src/vs/workbench/contrib/terminal/browser/terminalInstance.ts

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xterm
6868
import { ITerminalCommand, TerminalCapability } from 'vs/workbench/contrib/terminal/common/capabilities/capabilities';
6969
import { TerminalCapabilityStoreMultiplexer } from 'vs/workbench/contrib/terminal/common/capabilities/terminalCapabilityStore';
7070
import { IEnvironmentVariableInfo } from 'vs/workbench/contrib/terminal/common/environmentVariable';
71+
import { getCommandHistory } from 'vs/workbench/contrib/terminal/common/history';
7172
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';
7273
import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey';
7374
import { formatMessageForTerminal } from 'vs/workbench/contrib/terminal/common/terminalStrings';
@@ -399,11 +400,17 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
399400
this._register(this.capabilities.onDidAddCapability(e => {
400401
this._logService.debug('terminalInstance added capability', e);
401402
if (e === TerminalCapability.CwdDetection) {
402-
this.capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd((e) => {
403+
this.capabilities.get(TerminalCapability.CwdDetection)?.onDidChangeCwd(e => {
403404
this._cwd = e;
404405
this._xtermOnKey?.dispose();
405406
this.refreshTabLabels(this.title, TitleEventSource.Config);
406407
});
408+
} else if (e === TerminalCapability.CommandDetection) {
409+
this.capabilities.get(TerminalCapability.CommandDetection)?.onCommandFinished(e => {
410+
if (e.command.trim().length > 0) {
411+
this._instantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._shellType });
412+
}
413+
});
407414
}
408415
}));
409416
this._register(this.capabilities.onDidRemoveCapability(e => this._logService.debug('terminalInstance removed capability', e)));
@@ -756,65 +763,104 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
756763
}
757764

758765
async runRecent(type: 'command' | 'cwd'): Promise<void> {
759-
const commands = this.capabilities.get(TerminalCapability.CommandDetection)?.commands;
760-
if (!commands || !this.xterm) {
766+
if (!this.xterm) {
761767
return;
762768
}
763769
type Item = IQuickPickItem & { command?: ITerminalCommand };
764-
const items: Item[] = [];
770+
let items: (Item | IQuickPickItem | IQuickPickSeparator)[] = [];
771+
const commandMap: Set<string> = new Set();
772+
773+
const removeFromCommandHistoryButton: IQuickInputButton = {
774+
iconClass: ThemeIcon.asClassName(Codicon.close),
775+
tooltip: nls.localize('removeCommand', "Remove from Command History")
776+
};
777+
765778
if (type === 'command') {
766-
for (const entry of commands) {
767-
// trim off any whitespace and/or line endings
768-
const label = entry.command.trim();
769-
if (label.length === 0) {
770-
continue;
771-
}
772-
let detail = '';
773-
if (entry.cwd) {
774-
detail += `cwd: ${entry.cwd} `;
775-
}
776-
if (entry.exitCode) {
777-
// Since you cannot get the last command's exit code on pwsh, just whether it failed
778-
// or not, -1 is treated specially as simply failed
779-
if (entry.exitCode === -1) {
780-
detail += 'failed';
781-
} else {
782-
detail += `exitCode: ${entry.exitCode}`;
779+
const commands = this.capabilities.get(TerminalCapability.CommandDetection)?.commands;
780+
// Current session history
781+
if (commands && commands.length > 0) {
782+
for (const entry of commands) {
783+
// trim off any whitespace and/or line endings
784+
const label = entry.command.trim();
785+
if (label.length === 0) {
786+
continue;
787+
}
788+
let description = fromNow(entry.timestamp, true);
789+
if (entry.cwd) {
790+
description += ` @ ${entry.cwd}`;
783791
}
792+
if (entry.exitCode) {
793+
// Since you cannot get the last command's exit code on pwsh, just whether it failed
794+
// or not, -1 is treated specially as simply failed
795+
if (entry.exitCode === -1) {
796+
description += ' failed';
797+
} else {
798+
description += ` exitCode: ${entry.exitCode}`;
799+
}
800+
}
801+
description = description.trim();
802+
const iconClass = ThemeIcon.asClassName(Codicon.output);
803+
const buttons: IQuickInputButton[] = [{
804+
iconClass,
805+
tooltip: nls.localize('viewCommandOutput', "View Command Output"),
806+
alwaysVisible: true
807+
}];
808+
// Merge consecutive commands
809+
const lastItem = items.length > 0 ? items[items.length - 1] : undefined;
810+
if (lastItem?.type !== 'separator' && lastItem?.label === label) {
811+
lastItem.id = entry.timestamp.toString();
812+
lastItem.description = description;
813+
continue;
814+
}
815+
items.push({
816+
label,
817+
description,
818+
id: entry.timestamp.toString(),
819+
command: entry,
820+
buttons: (!entry.endMarker?.isDisposed && !entry.marker?.isDisposed && (entry.endMarker!.line - entry.marker!.line > 0)) ? buttons : undefined
821+
});
822+
commandMap.add(label);
784823
}
785-
detail = detail.trim();
786-
const iconClass = ThemeIcon.asClassName(Codicon.output);
787-
const buttons: IQuickInputButton[] = [{
788-
iconClass,
789-
tooltip: nls.localize('viewCommandOutput', "View Command Output"),
790-
alwaysVisible: true
791-
}];
792-
// Merge consecutive commands
793-
if (items.length > 0 && items[items.length - 1].label === label) {
794-
items[items.length - 1].id = entry.timestamp.toString();
795-
items[items.length - 1].detail = detail;
796-
continue;
824+
items = items.reverse();
825+
items.unshift({ type: 'separator', label: 'current session' });
826+
}
827+
828+
// Gather previous session history
829+
const history = this._instantiationService.invokeFunction(getCommandHistory);
830+
const previousSessionItems: IQuickPickItem[] = [];
831+
for (const [label, info] of history.entries) {
832+
// Only add previous session item if it's not in this session
833+
if (!commandMap.has(label) && info.shellType === this.shellType) {
834+
previousSessionItems.push({
835+
label,
836+
buttons: [removeFromCommandHistoryButton]
837+
});
797838
}
798-
items.push({
799-
label,
800-
description: fromNow(entry.timestamp, true),
801-
detail,
802-
id: entry.timestamp.toString(),
803-
command: entry,
804-
buttons: (!entry.endMarker?.isDisposed && !entry.marker?.isDisposed && (entry.endMarker!.line - entry.marker!.line > 0)) ? buttons : undefined
805-
});
839+
}
840+
if (previousSessionItems.length > 0) {
841+
items.push(
842+
{ type: 'separator', label: 'previous sessions' },
843+
...previousSessionItems
844+
);
806845
}
807846
} else {
808847
const cwds = this.capabilities.get(TerminalCapability.CwdDetection)?.cwds || [];
809848
for (const label of cwds) {
810849
items.push({ label });
811850
}
851+
items = items.reverse();
852+
}
853+
if (items.length === 0) {
854+
return;
812855
}
813856
const outputProvider = this._instantiationService.createInstance(TerminalOutputProvider);
814857
const quickPick = this._quickInputService.createQuickPick();
815-
quickPick.items = items.reverse();
858+
quickPick.items = items;
816859
return new Promise<void>(r => {
817860
quickPick.onDidTriggerItemButton(async e => {
861+
if (e.button === removeFromCommandHistoryButton) {
862+
this._instantiationService.invokeFunction(getCommandHistory)?.remove(e.item.label);
863+
}
818864
const selectedCommand = (e.item as Item).command;
819865
const output = selectedCommand?.getOutput();
820866
if (output && selectedCommand?.command) {
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Disposable } from 'vs/base/common/lifecycle';
7+
import { LRUCache } from 'vs/base/common/map';
8+
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
9+
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
10+
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
11+
import { TerminalSettingId, TerminalShellType } from 'vs/platform/terminal/common/terminal';
12+
13+
/**
14+
* Tracks a list of generic entries.
15+
*/
16+
export interface ITerminalPersistedHistory<T> {
17+
/**
18+
* The persisted entries.
19+
*/
20+
readonly entries: IterableIterator<[string, T]>;
21+
/**
22+
* Adds an entry.
23+
*/
24+
add(key: string, value: T): void;
25+
/**
26+
* Removes an entry.
27+
*/
28+
remove(key: string): void;
29+
/**
30+
* Clears all entries.
31+
*/
32+
clear(): void;
33+
}
34+
35+
interface ISerializedCache<T> {
36+
entries: { key: string; value: T }[];
37+
}
38+
39+
const enum Constants {
40+
DefaultHistoryLimit = 100
41+
}
42+
43+
const enum StorageKeys {
44+
Entries = 'terminal.history.entries',
45+
Timestamp = 'terminal.history.timestamp'
46+
}
47+
48+
let commandHistory: ITerminalPersistedHistory<{ shellType: TerminalShellType }> | undefined = undefined;
49+
export function getCommandHistory(accessor: ServicesAccessor): ITerminalPersistedHistory<{ shellType: TerminalShellType }> {
50+
if (!commandHistory) {
51+
commandHistory = accessor.get(IInstantiationService).createInstance(TerminalPersistedHistory, 'commands') as TerminalPersistedHistory<{ shellType: TerminalShellType }>;
52+
}
53+
return commandHistory;
54+
}
55+
56+
export class TerminalPersistedHistory<T> extends Disposable implements ITerminalPersistedHistory<T> {
57+
private readonly _entries: LRUCache<string, T>;
58+
private _timestamp: number = 0;
59+
private _isReady = false;
60+
private _isStale = true;
61+
62+
get entries(): IterableIterator<[string, T]> {
63+
this._ensureUpToDate();
64+
return this._entries.entries();
65+
}
66+
67+
constructor(
68+
private readonly _storageDataKey: string,
69+
@IConfigurationService private readonly _configurationService: IConfigurationService,
70+
@IStorageService private readonly _storageService: IStorageService
71+
) {
72+
super();
73+
74+
// Init cache
75+
this._entries = new LRUCache<string, T>(this._getHistoryLimit());
76+
77+
// Listen for config changes to set history limit
78+
this._configurationService.onDidChangeConfiguration(e => {
79+
if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationCommandHistory)) {
80+
this._entries.limit = this._getHistoryLimit();
81+
}
82+
});
83+
84+
// Listen to cache changes from other windows
85+
this._storageService.onDidChangeValue(e => {
86+
if (e.key === this._getTimestampStorageKey() && !this._isStale) {
87+
this._isStale = this._storageService.getNumber(this._getTimestampStorageKey(), StorageScope.GLOBAL, 0) !== this._timestamp;
88+
}
89+
});
90+
}
91+
92+
add(key: string, value: T) {
93+
this._ensureUpToDate();
94+
this._entries.set(key, value);
95+
this._saveState();
96+
}
97+
98+
remove(key: string) {
99+
this._ensureUpToDate();
100+
this._entries.delete(key);
101+
this._saveState();
102+
}
103+
104+
clear() {
105+
this._ensureUpToDate();
106+
this._entries.clear();
107+
this._saveState();
108+
}
109+
110+
private _ensureUpToDate() {
111+
// Initial load
112+
if (!this._isReady) {
113+
this._loadState();
114+
this._isReady = true;
115+
}
116+
117+
// React to stale cache caused by another window
118+
if (this._isStale) {
119+
// Since state is saved whenever the entries change, it's a safe assumption that no
120+
// merging of entries needs to happen, just loading the new state.
121+
this._entries.clear();
122+
this._loadState();
123+
this._isStale = false;
124+
}
125+
}
126+
127+
private _loadState() {
128+
this._timestamp = this._storageService.getNumber(this._getTimestampStorageKey(), StorageScope.GLOBAL, 0);
129+
130+
// Load global entries plus
131+
const serialized = this._loadPersistedState();
132+
if (serialized) {
133+
for (const entry of serialized.entries) {
134+
this._entries.set(entry.key, entry.value);
135+
}
136+
}
137+
}
138+
139+
private _loadPersistedState(): ISerializedCache<T> | undefined {
140+
const raw = this._storageService.get(this._getEntriesStorageKey(), StorageScope.GLOBAL);
141+
if (raw === undefined || raw.length === 0) {
142+
return undefined;
143+
}
144+
let serialized: ISerializedCache<T> | undefined = undefined;
145+
try {
146+
serialized = JSON.parse(raw);
147+
} catch {
148+
// Invalid data
149+
return undefined;
150+
}
151+
return serialized;
152+
}
153+
154+
private _saveState() {
155+
const serialized: ISerializedCache<T> = { entries: [] };
156+
this._entries.forEach((value, key) => serialized.entries.push({ key, value }));
157+
this._storageService.store(this._getEntriesStorageKey(), JSON.stringify(serialized), StorageScope.GLOBAL, StorageTarget.MACHINE);
158+
this._timestamp = Date.now();
159+
this._storageService.store(this._getTimestampStorageKey(), this._timestamp, StorageScope.GLOBAL, StorageTarget.MACHINE);
160+
}
161+
162+
private _getHistoryLimit() {
163+
const historyLimit = this._configurationService.getValue(TerminalSettingId.ShellIntegrationCommandHistory);
164+
return typeof historyLimit === 'number' ? historyLimit : Constants.DefaultHistoryLimit;
165+
}
166+
167+
private _getTimestampStorageKey() {
168+
return `${StorageKeys.Timestamp}.${this._storageDataKey}`;
169+
}
170+
171+
private _getEntriesStorageKey() {
172+
return `${StorageKeys.Entries}.${this._storageDataKey}`;
173+
}
174+
}

0 commit comments

Comments
 (0)