From 7ee03be5d152e53f73b5b9a28e2fc3e51ed67844 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 1 Oct 2024 07:12:23 -0700 Subject: [PATCH] Move most of history into terminalContrib Part of #230129 --- .../contrib/terminal/browser/terminal.ts | 6 - .../terminal/browser/terminalActions.ts | 62 ---- .../terminal/browser/terminalExtensions.ts | 2 +- .../terminal/browser/terminalInstance.ts | 27 +- .../browser/terminalInstanceService.ts | 8 +- .../contrib/terminal/common/history.ts | 335 +---------------- .../contrib/terminal/terminal.all.ts | 1 + .../test/browser/terminalInstance.test.ts | 8 +- .../browser/terminal.history.contribution.ts | 152 ++++++++ .../browser/terminalRunRecentQuickPick.ts | 51 +-- .../terminalContrib/history/common/history.ts | 343 ++++++++++++++++++ .../history}/test/common/history.test.ts | 33 +- 12 files changed, 553 insertions(+), 475 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/history/browser/terminal.history.contribution.ts rename src/vs/workbench/contrib/{terminal => terminalContrib/history}/browser/terminalRunRecentQuickPick.ts (86%) create mode 100644 src/vs/workbench/contrib/terminalContrib/history/common/history.ts rename src/vs/workbench/contrib/{terminal => terminalContrib/history}/test/common/history.test.ts (93%) diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 6e8f31f6768..6b4c6d4cf16 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1035,12 +1035,6 @@ export interface ITerminalInstance extends IBaseTerminalInstance { */ changeColor(color?: string, skipQuickPick?: boolean): Promise; - /** - * Triggers a quick pick that displays recent commands or cwds. Selecting one will - * rerun it in the active terminal. - */ - runRecent(type: 'command' | 'cwd'): Promise; - /** * Attempts to detect and kill the process listening on specified port. * If successful, places commandToRun on the command line diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 94009b77a4b..ab76ef338b2 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -47,7 +47,6 @@ import { AbstractVariableResolverService } from '../../../services/configuration import { ITerminalQuickPickItem } from './terminalProfileQuickpick.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { getIconId, getColorClass, getUriClasses } from './terminalIcon.js'; -import { clearShellFileHistory, getCommandHistory } from '../common/history.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -433,33 +432,6 @@ export function registerTerminalActions() { } }); - registerActiveInstanceAction({ - id: TerminalCommandId.RunRecentCommand, - title: localize2('workbench.action.terminal.runRecentCommand', 'Run Recent Command...'), - precondition: sharedWhenClause.terminalAvailable, - keybinding: [ - { - primary: KeyMod.CtrlCmd | KeyCode.KeyR, - when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, ContextKeyExpr.or(TerminalContextKeys.focus, ContextKeyExpr.and(accessibleViewIsShown, accessibleViewCurrentProviderId.isEqualTo(AccessibleViewProviderId.Terminal)))), - weight: KeybindingWeight.WorkbenchContrib - }, - { - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyR, - mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.KeyR }, - when: ContextKeyExpr.and(TerminalContextKeys.focus, CONTEXT_ACCESSIBILITY_MODE_ENABLED.negate()), - weight: KeybindingWeight.WorkbenchContrib - } - ], - run: async (activeInstance, c) => { - await activeInstance.runRecent('command'); - if (activeInstance?.target === TerminalLocation.Editor) { - await c.editorService.revealActiveEditor(); - } else { - await c.groupService.showPanel(false); - } - } - }); - registerActiveInstanceAction({ id: TerminalCommandId.CopyLastCommand, title: localize2('workbench.action.terminal.copyLastCommand', "Copy Last Command"), @@ -520,29 +492,6 @@ export function registerTerminalActions() { } }); - - registerActiveInstanceAction({ - id: TerminalCommandId.GoToRecentDirectory, - title: localize2('workbench.action.terminal.goToRecentDirectory', 'Go to Recent Directory...'), - metadata: { - description: localize2('goToRecentDirectory.metadata', 'Goes to a recent folder'), - }, - precondition: sharedWhenClause.terminalAvailable, - keybinding: { - primary: KeyMod.CtrlCmd | KeyCode.KeyG, - when: TerminalContextKeys.focus, - weight: KeybindingWeight.WorkbenchContrib - }, - run: async (activeInstance, c) => { - await activeInstance.runRecent('cwd'); - if (activeInstance?.target === TerminalLocation.Editor) { - await c.editorService.revealActiveEditor(); - } else { - await c.groupService.showPanel(false); - } - } - }); - registerTerminalAction({ id: TerminalCommandId.ResizePaneLeft, title: localize2('workbench.action.terminal.resizePaneLeft', 'Resize Terminal Left'), @@ -1502,17 +1451,6 @@ export function registerTerminalActions() { }, run: (instance) => instance.toggleSizeToContentWidth() }); - - registerTerminalAction({ - id: TerminalCommandId.ClearPreviousSessionHistory, - title: localize2('workbench.action.terminal.clearPreviousSessionHistory', 'Clear Previous Session History'), - precondition: sharedWhenClause.terminalAvailable, - run: async (c, accessor) => { - getCommandHistory(accessor).clear(); - clearShellFileHistory(); - } - }); - // Some commands depend on platform features if (BrowserFeatures.clipboard.writeText) { registerActiveXtermAction({ diff --git a/src/vs/workbench/contrib/terminal/browser/terminalExtensions.ts b/src/vs/workbench/contrib/terminal/browser/terminalExtensions.ts index ed87cf5dc31..7e3b2f1a539 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalExtensions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalExtensions.ts @@ -22,7 +22,7 @@ export type ITerminalContributionDescription = { readonly id: string } & ( export function registerTerminalContribution(id: string, ctor: { new(instance: ITerminalInstance, processManager: ITerminalProcessManager, widgetManager: TerminalWidgetManager, ...services: Services): ITerminalContribution }, canRunInDetachedTerminals?: false): void; export function registerTerminalContribution(id: string, ctor: { new(instance: ITerminalInstance, processManager: ITerminalProcessInfo, widgetManager: TerminalWidgetManager, ...services: Services): ITerminalContribution }, canRunInDetachedTerminals: true): void; export function registerTerminalContribution(id: string, ctor: { new(instance: ITerminalInstance, processManager: ITerminalProcessManager, widgetManager: TerminalWidgetManager, ...services: Services): ITerminalContribution }, canRunInDetachedTerminals = false): void { - // eslint-disable-next-line local/code-no-dangerous-type-assertions + // TODO: Pass in a context object containing instance, process manager, widgetmanager TerminalContributionRegistry.INSTANCE.registerTerminalContribution({ id, ctor, canRunInDetachedTerminals } as ITerminalContributionDescription); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 5b97125338a..f14fa502678 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -64,14 +64,13 @@ import { TerminalEditorInput } from './terminalEditorInput.js'; import { TerminalExtensionsRegistry } from './terminalExtensions.js'; import { getColorClass, createColorStyleElement, getStandardColors } from './terminalIcon.js'; import { TerminalProcessManager } from './terminalProcessManager.js'; -import { showRunRecentQuickPick } from './terminalRunRecentQuickPick.js'; import { ITerminalStatusList, TerminalStatus, TerminalStatusList } from './terminalStatusList.js'; import { getTerminalResourcesFromDragEvent, getTerminalUri } from './terminalUri.js'; import { TerminalWidgetManager } from './widgets/widgetManager.js'; import { LineDataEventAddon } from './xterm/lineDataEventAddon.js'; import { XtermTerminal, getXtermScaledDimensions } from './xterm/xtermTerminal.js'; import { IEnvironmentVariableInfo } from '../common/environmentVariable.js'; -import { getCommandHistory, getDirectoryHistory } from '../common/history.js'; +import { getDirectoryHistory } from '../common/history.js'; import { DEFAULT_COMMANDS_TO_SKIP_SHELL, ITerminalProcessManager, ITerminalProfileResolverService, ProcessState, TERMINAL_CREATION_COMMANDS, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; import { TERMINAL_BACKGROUND_COLOR } from '../common/terminalColorRegistry.js'; import { TerminalContextKeys } from '../common/terminalContextKey.js'; @@ -362,7 +361,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { constructor( private readonly _terminalShellTypeContextKey: IContextKey, - private readonly _terminalInRunCommandPicker: IContextKey, private _shellLaunchConfig: IShellLaunchConfig, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @@ -450,6 +448,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._terminalShellIntegrationEnabledContextKey = TerminalContextKeys.terminalShellIntegrationEnabled.bindTo(scopedContextKeyService); this._logService.trace(`terminalInstance#ctor (instanceId: ${this.instanceId})`, this._shellLaunchConfig); + this._register(this.capabilities.onDidAddCapabilityType(e => this._logService.debug('terminalInstance added capability', e))); + this._register(this.capabilities.onDidRemoveCapabilityType(e => this._logService.debug('terminalInstance removed capability', e))); this._register(this.capabilities.onDidAddCapabilityType(e => { this._logService.debug('terminalInstance added capability', e); if (e === TerminalCapability.CwdDetection) { @@ -458,16 +458,15 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._setTitle(this.title, TitleEventSource.Config); this._scopedInstantiationService.invokeFunction(getDirectoryHistory)?.add(e, { remoteAuthority: this.remoteAuthority }); }); - } else if (e === TerminalCapability.CommandDetection) { - const commandCapability = this.capabilities.get(TerminalCapability.CommandDetection); - commandCapability?.onCommandFinished(e => { - if (e.command.trim().length > 0) { - this._scopedInstantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._shellType }); - } - }); + // } else if (e === TerminalCapability.CommandDetection) { + // const commandCapability = this.capabilities.get(TerminalCapability.CommandDetection); + // commandCapability?.onCommandFinished(e => { + // if (e.command.trim().length > 0) { + // this._scopedInstantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._shellType }); + // } + // }); } })); - this._register(this.capabilities.onDidRemoveCapabilityType(e => this._logService.debug('terminalInstance removed capability', e))); // Resolve just the icon ahead of time so that it shows up immediately in the tabs. This is // disabled in remote because this needs to be sync and the OS may differ on the remote @@ -898,12 +897,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { await this.sendText(commandLine, shouldExecute, !shouldExecute); } - async runRecent(type: 'command' | 'cwd', filterMode?: 'fuzzy' | 'contiguous', value?: string): Promise { - return this._scopedInstantiationService.invokeFunction( - showRunRecentQuickPick, this, this._terminalInRunCommandPicker, type, filterMode, value - ); - } - detachFromElement(): void { this._wrapperElement.remove(); this._container = undefined; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts index 43084b73d1b..d1f7b5d8adc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts @@ -20,7 +20,6 @@ import { promiseWithResolvers } from '../../../../base/common/async.js'; export class TerminalInstanceService extends Disposable implements ITerminalInstanceService { declare _serviceBrand: undefined; private _terminalShellTypeContextKey: IContextKey; - private _terminalInRunCommandPicker: IContextKey; private _backendRegistration = new Map; resolve: () => void }>(); private readonly _onDidCreateInstance = this._register(new Emitter()); @@ -36,7 +35,6 @@ export class TerminalInstanceService extends Disposable implements ITerminalInst ) { super(); this._terminalShellTypeContextKey = TerminalContextKeys.shellType.bindTo(this._contextKeyService); - this._terminalInRunCommandPicker = TerminalContextKeys.inTerminalRunCommandPicker.bindTo(this._contextKeyService); for (const remoteAuthority of [undefined, environmentService.remoteAuthority]) { const { promise, resolve } = promiseWithResolvers(); @@ -48,11 +46,7 @@ export class TerminalInstanceService extends Disposable implements ITerminalInst createInstance(shellLaunchConfig: IShellLaunchConfig, target: TerminalLocation): ITerminalInstance; createInstance(config: IShellLaunchConfig | ITerminalProfile, target: TerminalLocation): ITerminalInstance { const shellLaunchConfig = this.convertProfileToShellLaunchConfig(config); - const instance = this._instantiationService.createInstance(TerminalInstance, - this._terminalShellTypeContextKey, - this._terminalInRunCommandPicker, - shellLaunchConfig - ); + const instance = this._instantiationService.createInstance(TerminalInstance, this._terminalShellTypeContextKey, shellLaunchConfig); instance.target = target; this._onDidCreateInstance.fire(instance); return instance; diff --git a/src/vs/workbench/contrib/terminal/common/history.ts b/src/vs/workbench/contrib/terminal/common/history.ts index 6a01ca8eb99..fc4a96c7a2a 100644 --- a/src/vs/workbench/contrib/terminal/common/history.ts +++ b/src/vs/workbench/contrib/terminal/common/history.ts @@ -3,19 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { env } from '../../../../base/common/process.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { FileOperationError, FileOperationResult, IFileContent, IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { GeneralShellType, PosixShellType, TerminalSettingId, TerminalShellType } from '../../../../platform/terminal/common/terminal.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { isWindows, OperatingSystem } from '../../../../base/common/platform.js'; -import { join } from '../../../../base/common/path.js'; +import { TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; /** * Tracks a list of generic entries. @@ -52,14 +45,6 @@ const enum StorageKeys { Timestamp = 'terminal.history.timestamp' } -let commandHistory: ITerminalPersistedHistory<{ shellType: TerminalShellType }> | undefined = undefined; -export function getCommandHistory(accessor: ServicesAccessor): ITerminalPersistedHistory<{ shellType: TerminalShellType | undefined }> { - if (!commandHistory) { - commandHistory = accessor.get(IInstantiationService).createInstance(TerminalPersistedHistory, 'commands') as TerminalPersistedHistory<{ shellType: TerminalShellType }>; - } - return commandHistory; -} - let directoryHistory: ITerminalPersistedHistory<{ remoteAuthority?: string }> | undefined = undefined; export function getDirectoryHistory(accessor: ServicesAccessor): ITerminalPersistedHistory<{ remoteAuthority?: string }> { if (!directoryHistory) { @@ -68,46 +53,6 @@ export function getDirectoryHistory(accessor: ServicesAccessor): ITerminalPersis return directoryHistory; } -// Shell file history loads once per shell per window -const shellFileHistory: Map = new Map(); -export async function getShellFileHistory(accessor: ServicesAccessor, shellType: TerminalShellType | undefined): Promise { - const cached = shellFileHistory.get(shellType); - if (cached === null) { - return []; - } - if (cached !== undefined) { - return cached; - } - let result: IterableIterator | undefined; - switch (shellType) { - case PosixShellType.Bash: - result = await fetchBashHistory(accessor); - break; - case GeneralShellType.PowerShell: - result = await fetchPwshHistory(accessor); - break; - case PosixShellType.Zsh: - result = await fetchZshHistory(accessor); - break; - case PosixShellType.Fish: - result = await fetchFishHistory(accessor); - break; - case GeneralShellType.Python: - result = await fetchPythonHistory(accessor); - break; - default: return []; - } - if (result === undefined) { - shellFileHistory.set(shellType, null); - return []; - } - const array = Array.from(result); - shellFileHistory.set(shellType, array); - return array; -} -export function clearShellFileHistory() { - shellFileHistory.clear(); -} export class TerminalPersistedHistory extends Disposable implements ITerminalPersistedHistory { private readonly _entries: LRUCache; @@ -228,281 +173,3 @@ export class TerminalPersistedHistory extends Disposable implements ITerminal return `${StorageKeys.Entries}.${this._storageDataKey}`; } } - -export async function fetchBashHistory(accessor: ServicesAccessor): Promise | undefined> { - const fileService = accessor.get(IFileService); - const remoteAgentService = accessor.get(IRemoteAgentService); - const remoteEnvironment = await remoteAgentService.getEnvironment(); - if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { - return undefined; - } - const content = await fetchFileContents(env['HOME'], '.bash_history', false, fileService, remoteAgentService); - if (content === undefined) { - return undefined; - } - // .bash_history does not differentiate wrapped commands from multiple commands. Parse - // the output to get the - const fileLines = content.split('\n'); - const result: Set = new Set(); - let currentLine: string; - let currentCommand: string | undefined = undefined; - let wrapChar: string | undefined = undefined; - for (let i = 0; i < fileLines.length; i++) { - currentLine = fileLines[i]; - if (currentCommand === undefined) { - currentCommand = currentLine; - } else { - currentCommand += `\n${currentLine}`; - } - for (let c = 0; c < currentLine.length; c++) { - if (wrapChar) { - if (currentLine[c] === wrapChar) { - wrapChar = undefined; - } - } else { - if (currentLine[c].match(/['"]/)) { - wrapChar = currentLine[c]; - } - } - } - if (wrapChar === undefined) { - if (currentCommand.length > 0) { - result.add(currentCommand.trim()); - } - currentCommand = undefined; - } - } - - return result.values(); -} - -export async function fetchZshHistory(accessor: ServicesAccessor) { - const fileService = accessor.get(IFileService); - const remoteAgentService = accessor.get(IRemoteAgentService); - const remoteEnvironment = await remoteAgentService.getEnvironment(); - if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { - return undefined; - } - const content = await fetchFileContents(env['HOME'], '.zsh_history', false, fileService, remoteAgentService); - if (content === undefined) { - return undefined; - } - const fileLines = content.split(/\:\s\d+\:\d+;/); - const result: Set = new Set(); - for (let i = 0; i < fileLines.length; i++) { - const sanitized = fileLines[i].replace(/\\\n/g, '\n').trim(); - if (sanitized.length > 0) { - result.add(sanitized); - } - } - return result.values(); -} - - -export async function fetchPythonHistory(accessor: ServicesAccessor): Promise | undefined> { - const fileService = accessor.get(IFileService); - const remoteAgentService = accessor.get(IRemoteAgentService); - - const content = await fetchFileContents(env['HOME'], '.python_history', false, fileService, remoteAgentService); - - if (content === undefined) { - return undefined; - } - - // Python history file is a simple text file with one command per line - const fileLines = content.split('\n'); - const result: Set = new Set(); - - fileLines.forEach(line => { - if (line.trim().length > 0) { - result.add(line.trim()); - } - }); - - return result.values(); -} - -export async function fetchPwshHistory(accessor: ServicesAccessor) { - const fileService: Pick = accessor.get(IFileService); - const remoteAgentService: Pick = accessor.get(IRemoteAgentService); - let folderPrefix: string | undefined; - let filePath: string; - const remoteEnvironment = await remoteAgentService.getEnvironment(); - const isFileWindows = remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows; - if (isFileWindows) { - folderPrefix = env['APPDATA']; - filePath = 'Microsoft\\Windows\\PowerShell\\PSReadLine\\ConsoleHost_history.txt'; - } else { - folderPrefix = env['HOME']; - filePath = '.local/share/powershell/PSReadline/ConsoleHost_history.txt'; - } - const content = await fetchFileContents(folderPrefix, filePath, isFileWindows, fileService, remoteAgentService); - if (content === undefined) { - return undefined; - } - const fileLines = content.split('\n'); - const result: Set = new Set(); - let currentLine: string; - let currentCommand: string | undefined = undefined; - let wrapChar: string | undefined = undefined; - for (let i = 0; i < fileLines.length; i++) { - currentLine = fileLines[i]; - if (currentCommand === undefined) { - currentCommand = currentLine; - } else { - currentCommand += `\n${currentLine}`; - } - if (!currentLine.endsWith('`')) { - const sanitized = currentCommand.trim(); - if (sanitized.length > 0) { - result.add(sanitized); - } - currentCommand = undefined; - continue; - } - // If the line ends with `, the line may be wrapped. Need to also test the case where ` is - // the last character in the line - for (let c = 0; c < currentLine.length; c++) { - if (wrapChar) { - if (currentLine[c] === wrapChar) { - wrapChar = undefined; - } - } else { - if (currentLine[c].match(/`/)) { - wrapChar = currentLine[c]; - } - } - } - // Having an even number of backticks means the line is terminated - // TODO: This doesn't cover more complicated cases where ` is within quotes - if (!wrapChar) { - const sanitized = currentCommand.trim(); - if (sanitized.length > 0) { - result.add(sanitized); - } - currentCommand = undefined; - } else { - // Remove trailing backtick - currentCommand = currentCommand.replace(/`$/, ''); - wrapChar = undefined; - } - } - - return result.values(); -} - -export async function fetchFishHistory(accessor: ServicesAccessor) { - const fileService = accessor.get(IFileService); - const remoteAgentService = accessor.get(IRemoteAgentService); - const remoteEnvironment = await remoteAgentService.getEnvironment(); - if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { - return undefined; - } - - /** - * From `fish` docs: - * > The command history is stored in the file ~/.local/share/fish/fish_history - * (or $XDG_DATA_HOME/fish/fish_history if that variable is set) by default. - * - * (https://fishshell.com/docs/current/interactive.html#history-search) - */ - const overridenDataHome = env['XDG_DATA_HOME']; - - // TODO: Unchecked fish behavior: - // What if XDG_DATA_HOME was defined but somehow $XDG_DATA_HOME/fish/fish_history - // was not exist. Does fish fall back to ~/.local/share/fish/fish_history? - - const content = await (overridenDataHome - ? fetchFileContents(env['XDG_DATA_HOME'], 'fish/fish_history', false, fileService, remoteAgentService) - : fetchFileContents(env['HOME'], '.local/share/fish/fish_history', false, fileService, remoteAgentService)); - if (content === undefined) { - return undefined; - } - - /** - * These apply to `fish` v3.5.1: - * - It looks like YAML but it's not. It's, quoting, *"a broken psuedo-YAML"*. - * See these discussions for more details: - * - https://github.com/fish-shell/fish-shell/pull/6493 - * - https://github.com/fish-shell/fish-shell/issues/3341 - * - Every record should exactly start with `- cmd:` (the whitespace between `-` and `cmd` cannot be replaced with tab) - * - Both `- cmd: echo 1` and `- cmd:echo 1` are valid entries. - * - Backslashes are esacped as `\\`. - * - Multiline commands are joined with a `\n` sequence, hence they're read as single line commands. - * - Property `when` is optional. - * - History navigation respects the records order and ignore the actual `when` property values (chronological order). - * - If `cmd` value is multiline , it just takes the first line. Also YAML operators like `>-` or `|-` are not supported. - */ - const result: Set = new Set(); - const cmds = content.split('\n') - .filter(x => x.startsWith('- cmd:')) - .map(x => x.substring(6).trimStart()); - for (let i = 0; i < cmds.length; i++) { - const sanitized = sanitizeFishHistoryCmd(cmds[i]).trim(); - if (sanitized.length > 0) { - result.add(sanitized); - } - } - return result.values(); -} - -export function sanitizeFishHistoryCmd(cmd: string): string { - /** - * NOTE - * This repeatedReplace() call can be eliminated by using look-ahead - * caluses in the original RegExp pattern: - * - * >>> ```ts - * >>> cmds[i].replace(/(?<=^|[^\\])((?:\\\\)*)(\\n)/g, '$1\n') - * >>> ``` - * - * But since not all browsers support look aheads we opted to a simple - * pattern and repeatedly calling replace method. - */ - return repeatedReplace(/(^|[^\\])((?:\\\\)*)(\\n)/g, cmd, '$1$2\n'); -} - -function repeatedReplace(pattern: RegExp, value: string, replaceValue: string): string { - let last; - let current = value; - while (true) { - last = current; - current = current.replace(pattern, replaceValue); - if (current === last) { - return current; - } - } -} - -async function fetchFileContents( - folderPrefix: string | undefined, - filePath: string, - isFileWindows: boolean, - fileService: Pick, - remoteAgentService: Pick, -): Promise { - if (!folderPrefix) { - return undefined; - } - const connection = remoteAgentService.getConnection(); - const isRemote = !!connection?.remoteAuthority; - const historyFileUri = URI.from({ - scheme: isRemote ? Schemas.vscodeRemote : Schemas.file, - authority: isRemote ? connection.remoteAuthority : undefined, - path: URI.file(join(folderPrefix, filePath)).path - }); - let content: IFileContent; - try { - content = await fileService.readFile(historyFileUri); - } catch (e: unknown) { - // Handle file not found only - if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { - return undefined; - } - throw e; - } - if (content === undefined) { - return undefined; - } - return content.value.toString(); -} diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 40cb22a0083..69ba6694072 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -20,6 +20,7 @@ import '../terminalContrib/environmentChanges/browser/terminal.environmentChange import '../terminalContrib/find/browser/terminal.find.contribution.js'; import '../terminalContrib/chat/browser/terminal.chat.contribution.js'; import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contribution.js'; +import '../terminalContrib/history/browser/terminal.history.contribution.js'; import '../terminalContrib/links/browser/terminal.links.contribution.js'; import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js'; import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js'; diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index dcddd8b4818..7302c21440e 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -58,12 +58,6 @@ const terminalShellTypeContextKey = { get: () => undefined }; -const terminalInRunCommandPicker = { - set: () => { }, - reset: () => { }, - get: () => undefined -}; - class TestTerminalChildProcess extends Disposable implements ITerminalChildProcess { id: number = 0; get capabilities() { return []; } @@ -150,7 +144,7 @@ suite('Workbench - TerminalInstance', () => { instantiationService.stub(IViewDescriptorService, new TestViewDescriptorService()); instantiationService.stub(IEnvironmentVariableService, store.add(instantiationService.createInstance(EnvironmentVariableService))); instantiationService.stub(ITerminalInstanceService, store.add(new TestTerminalInstanceService())); - terminalInstance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, terminalInRunCommandPicker, {})); + terminalInstance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); // //Wait for the teminalInstance._xtermReadyPromise to resolve await new Promise(resolve => setTimeout(resolve, 100)); deepStrictEqual(terminalInstance.shellLaunchConfig.env, { TEST: 'TEST' }); diff --git a/src/vs/workbench/contrib/terminalContrib/history/browser/terminal.history.contribution.ts b/src/vs/workbench/contrib/terminalContrib/history/browser/terminal.history.contribution.ts new file mode 100644 index 00000000000..09892e82e95 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/history/browser/terminal.history.contribution.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../../nls.js'; +import { AccessibleViewProviderId } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ContextKeyExpr, IContextKeyService, type IContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { TerminalLocation } from '../../../../../platform/terminal/common/terminal.js'; +import { accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import type { ITerminalContribution, ITerminalInstance } from '../../../terminal/browser/terminal.js'; +import { registerActiveInstanceAction, registerTerminalAction } from '../../../terminal/browser/terminalActions.js'; +import { registerTerminalContribution } from '../../../terminal/browser/terminalExtensions.js'; +import type { TerminalWidgetManager } from '../../../terminal/browser/widgets/widgetManager.js'; +import { TerminalCommandId, type ITerminalProcessManager } from '../../../terminal/common/terminal.js'; +import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; +import { clearShellFileHistory, getCommandHistory } from '../common/history.js'; +import { showRunRecentQuickPick } from './terminalRunRecentQuickPick.js'; + +// #region Terminal Contributions + +class TerminalHistoryContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.history'; + + static get(instance: ITerminalInstance): TerminalHistoryContribution | null { + return instance.getContribution(TerminalHistoryContribution.ID); + } + + private _terminalInRunCommandPicker: IContextKey; + + constructor( + private readonly _instance: ITerminalInstance, + processManager: ITerminalProcessManager, + widgetManager: TerminalWidgetManager, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this._terminalInRunCommandPicker = TerminalContextKeys.inTerminalRunCommandPicker.bindTo(this._contextKeyService); + + this._register(this._instance.capabilities.onDidAddCapabilityType(e => { + if (e === TerminalCapability.CommandDetection) { + const commandCapability = this._instance.capabilities.get(TerminalCapability.CommandDetection); + commandCapability?.onCommandFinished(e => { + if (e.command.trim().length > 0) { + this._instantiationService.invokeFunction(getCommandHistory)?.add(e.command, { shellType: this._instance.shellType }); + } + }); + } + })); + } + + /** + * Triggers a quick pick that displays recent commands or cwds. Selecting one will + * rerun it in the active terminal. + */ + async runRecent(type: 'command' | 'cwd', filterMode?: 'fuzzy' | 'contiguous', value?: string): Promise { + return this._instantiationService.invokeFunction(showRunRecentQuickPick, + this._instance, + this._terminalInRunCommandPicker, + type, + filterMode, + value + ); + } +} + +registerTerminalContribution(TerminalHistoryContribution.ID, TerminalHistoryContribution); + +// #endregion + +// #region Actions + +const precondition = ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated); + +registerActiveInstanceAction({ + id: TerminalCommandId.RunRecentCommand, + title: localize2('workbench.action.terminal.runRecentCommand', 'Run Recent Command...'), + precondition, + keybinding: [ + { + primary: KeyMod.CtrlCmd | KeyCode.KeyR, + when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, ContextKeyExpr.or(TerminalContextKeys.focus, ContextKeyExpr.and(accessibleViewIsShown, accessibleViewCurrentProviderId.isEqualTo(AccessibleViewProviderId.Terminal)))), + weight: KeybindingWeight.WorkbenchContrib + }, + { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyR, + mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.KeyR }, + when: ContextKeyExpr.and(TerminalContextKeys.focus, CONTEXT_ACCESSIBILITY_MODE_ENABLED.negate()), + weight: KeybindingWeight.WorkbenchContrib + } + ], + run: async (activeInstance, c) => { + const history = TerminalHistoryContribution.get(activeInstance); + if (!history) { + return; + } + await history.runRecent('command'); + if (activeInstance?.target === TerminalLocation.Editor) { + await c.editorService.revealActiveEditor(); + } else { + await c.groupService.showPanel(false); + } + } +}); + +// TODO: move command IDs into this file +registerActiveInstanceAction({ + id: TerminalCommandId.GoToRecentDirectory, + title: localize2('workbench.action.terminal.goToRecentDirectory', 'Go to Recent Directory...'), + metadata: { + description: localize2('goToRecentDirectory.metadata', 'Goes to a recent folder'), + }, + precondition, + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.KeyG, + when: TerminalContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib + }, + run: async (activeInstance, c) => { + const history = TerminalHistoryContribution.get(activeInstance); + if (!history) { + return; + } + await history.runRecent('cwd'); + if (activeInstance?.target === TerminalLocation.Editor) { + await c.editorService.revealActiveEditor(); + } else { + await c.groupService.showPanel(false); + } + } +}); + +registerTerminalAction({ + id: TerminalCommandId.ClearPreviousSessionHistory, + title: localize2('workbench.action.terminal.clearPreviousSessionHistory', 'Clear Previous Session History'), + precondition, + run: async (c, accessor) => { + getCommandHistory(accessor).clear(); + clearShellFileHistory(); + } +}); + + +// #endregion diff --git a/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts b/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts similarity index 86% rename from src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts rename to src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts index 14f7567ae09..79c2042d9bf 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts +++ b/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts @@ -3,31 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Toggle } from '../../../../base/browser/ui/toggle/toggle.js'; -import { isMacintosh, OperatingSystem } from '../../../../base/common/platform.js'; -import { ITextModel } from '../../../../editor/common/model.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { localize } from '../../../../nls.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; -import { ITerminalCommand, TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; -import { collapseTildePath } from '../../../../platform/terminal/common/terminalEnvironment.js'; -import { asCssVariable, inputActiveOptionBackground, inputActiveOptionBorder, inputActiveOptionForeground } from '../../../../platform/theme/common/colorRegistry.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ITerminalInstance } from './terminal.js'; -import { commandHistoryFuzzySearchIcon, commandHistoryOutputIcon, commandHistoryRemoveIcon } from './terminalIcons.js'; -import { getCommandHistory, getDirectoryHistory, getShellFileHistory } from '../common/history.js'; -import { TerminalStorageKeys } from '../common/terminalStorageKeys.js'; -import { terminalStrings } from '../common/terminalStrings.js'; -import { URI } from '../../../../base/common/uri.js'; -import { fromNow } from '../../../../base/common/date.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { showWithPinnedItems } from '../../../../platform/quickinput/browser/quickPickPin.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { AccessibleViewProviderId, IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Toggle } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { isMacintosh, OperatingSystem } from '../../../../../base/common/platform.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ITextModelContentProvider, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { collapseTildePath } from '../../../../../platform/terminal/common/terminalEnvironment.js'; +import { asCssVariable, inputActiveOptionBackground, inputActiveOptionBorder, inputActiveOptionForeground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ITerminalInstance } from '../../../terminal/browser/terminal.js'; +import { commandHistoryFuzzySearchIcon, commandHistoryOutputIcon, commandHistoryRemoveIcon } from '../../../terminal/browser/terminalIcons.js'; +import { getDirectoryHistory } from '../../../terminal/common/history.js'; +import { TerminalStorageKeys } from '../../../terminal/common/terminalStorageKeys.js'; +import { terminalStrings } from '../../../terminal/common/terminalStrings.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { fromNow } from '../../../../../base/common/date.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { showWithPinnedItems } from '../../../../../platform/quickinput/browser/quickPickPin.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { AccessibleViewProviderId, IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { getCommandHistory, getShellFileHistory } from '../common/history.js'; export async function showRunRecentQuickPick( accessor: ServicesAccessor, diff --git a/src/vs/workbench/contrib/terminalContrib/history/common/history.ts b/src/vs/workbench/contrib/terminalContrib/history/common/history.ts new file mode 100644 index 00000000000..90f7082a755 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/history/common/history.ts @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../../../base/common/network.js'; +import { join } from '../../../../../base/common/path.js'; +import { isWindows, OperatingSystem } from '../../../../../base/common/platform.js'; +import { env } from '../../../../../base/common/process.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { FileOperationError, FileOperationResult, IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { GeneralShellType, PosixShellType, TerminalShellType } from '../../../../../platform/terminal/common/terminal.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { TerminalPersistedHistory, type ITerminalPersistedHistory } from '../../../terminal/common/history.js'; + + +let commandHistory: ITerminalPersistedHistory<{ shellType: TerminalShellType }> | undefined = undefined; +export function getCommandHistory(accessor: ServicesAccessor): ITerminalPersistedHistory<{ shellType: TerminalShellType | undefined }> { + if (!commandHistory) { + commandHistory = accessor.get(IInstantiationService).createInstance(TerminalPersistedHistory, 'commands') as TerminalPersistedHistory<{ shellType: TerminalShellType }>; + } + return commandHistory; +} + +// Shell file history loads once per shell per window +const shellFileHistory: Map = new Map(); +export async function getShellFileHistory(accessor: ServicesAccessor, shellType: TerminalShellType | undefined): Promise { + const cached = shellFileHistory.get(shellType); + if (cached === null) { + return []; + } + if (cached !== undefined) { + return cached; + } + let result: IterableIterator | undefined; + switch (shellType) { + case PosixShellType.Bash: + result = await fetchBashHistory(accessor); + break; + case GeneralShellType.PowerShell: + result = await fetchPwshHistory(accessor); + break; + case PosixShellType.Zsh: + result = await fetchZshHistory(accessor); + break; + case PosixShellType.Fish: + result = await fetchFishHistory(accessor); + break; + case GeneralShellType.Python: + result = await fetchPythonHistory(accessor); + break; + default: return []; + } + if (result === undefined) { + shellFileHistory.set(shellType, null); + return []; + } + const array = Array.from(result); + shellFileHistory.set(shellType, array); + return array; +} +export function clearShellFileHistory() { + shellFileHistory.clear(); +} + +export async function fetchBashHistory(accessor: ServicesAccessor): Promise | undefined> { + const fileService = accessor.get(IFileService); + const remoteAgentService = accessor.get(IRemoteAgentService); + const remoteEnvironment = await remoteAgentService.getEnvironment(); + if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { + return undefined; + } + const content = await fetchFileContents(env['HOME'], '.bash_history', false, fileService, remoteAgentService); + if (content === undefined) { + return undefined; + } + // .bash_history does not differentiate wrapped commands from multiple commands. Parse + // the output to get the + const fileLines = content.split('\n'); + const result: Set = new Set(); + let currentLine: string; + let currentCommand: string | undefined = undefined; + let wrapChar: string | undefined = undefined; + for (let i = 0; i < fileLines.length; i++) { + currentLine = fileLines[i]; + if (currentCommand === undefined) { + currentCommand = currentLine; + } else { + currentCommand += `\n${currentLine}`; + } + for (let c = 0; c < currentLine.length; c++) { + if (wrapChar) { + if (currentLine[c] === wrapChar) { + wrapChar = undefined; + } + } else { + if (currentLine[c].match(/['"]/)) { + wrapChar = currentLine[c]; + } + } + } + if (wrapChar === undefined) { + if (currentCommand.length > 0) { + result.add(currentCommand.trim()); + } + currentCommand = undefined; + } + } + + return result.values(); +} + +export async function fetchZshHistory(accessor: ServicesAccessor) { + const fileService = accessor.get(IFileService); + const remoteAgentService = accessor.get(IRemoteAgentService); + const remoteEnvironment = await remoteAgentService.getEnvironment(); + if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { + return undefined; + } + const content = await fetchFileContents(env['HOME'], '.zsh_history', false, fileService, remoteAgentService); + if (content === undefined) { + return undefined; + } + const fileLines = content.split(/\:\s\d+\:\d+;/); + const result: Set = new Set(); + for (let i = 0; i < fileLines.length; i++) { + const sanitized = fileLines[i].replace(/\\\n/g, '\n').trim(); + if (sanitized.length > 0) { + result.add(sanitized); + } + } + return result.values(); +} + + +export async function fetchPythonHistory(accessor: ServicesAccessor): Promise | undefined> { + const fileService = accessor.get(IFileService); + const remoteAgentService = accessor.get(IRemoteAgentService); + + const content = await fetchFileContents(env['HOME'], '.python_history', false, fileService, remoteAgentService); + + if (content === undefined) { + return undefined; + } + + // Python history file is a simple text file with one command per line + const fileLines = content.split('\n'); + const result: Set = new Set(); + + fileLines.forEach(line => { + if (line.trim().length > 0) { + result.add(line.trim()); + } + }); + + return result.values(); +} + +export async function fetchPwshHistory(accessor: ServicesAccessor) { + const fileService: Pick = accessor.get(IFileService); + const remoteAgentService: Pick = accessor.get(IRemoteAgentService); + let folderPrefix: string | undefined; + let filePath: string; + const remoteEnvironment = await remoteAgentService.getEnvironment(); + const isFileWindows = remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows; + if (isFileWindows) { + folderPrefix = env['APPDATA']; + filePath = 'Microsoft\\Windows\\PowerShell\\PSReadLine\\ConsoleHost_history.txt'; + } else { + folderPrefix = env['HOME']; + filePath = '.local/share/powershell/PSReadline/ConsoleHost_history.txt'; + } + const content = await fetchFileContents(folderPrefix, filePath, isFileWindows, fileService, remoteAgentService); + if (content === undefined) { + return undefined; + } + const fileLines = content.split('\n'); + const result: Set = new Set(); + let currentLine: string; + let currentCommand: string | undefined = undefined; + let wrapChar: string | undefined = undefined; + for (let i = 0; i < fileLines.length; i++) { + currentLine = fileLines[i]; + if (currentCommand === undefined) { + currentCommand = currentLine; + } else { + currentCommand += `\n${currentLine}`; + } + if (!currentLine.endsWith('`')) { + const sanitized = currentCommand.trim(); + if (sanitized.length > 0) { + result.add(sanitized); + } + currentCommand = undefined; + continue; + } + // If the line ends with `, the line may be wrapped. Need to also test the case where ` is + // the last character in the line + for (let c = 0; c < currentLine.length; c++) { + if (wrapChar) { + if (currentLine[c] === wrapChar) { + wrapChar = undefined; + } + } else { + if (currentLine[c].match(/`/)) { + wrapChar = currentLine[c]; + } + } + } + // Having an even number of backticks means the line is terminated + // TODO: This doesn't cover more complicated cases where ` is within quotes + if (!wrapChar) { + const sanitized = currentCommand.trim(); + if (sanitized.length > 0) { + result.add(sanitized); + } + currentCommand = undefined; + } else { + // Remove trailing backtick + currentCommand = currentCommand.replace(/`$/, ''); + wrapChar = undefined; + } + } + + return result.values(); +} + +export async function fetchFishHistory(accessor: ServicesAccessor) { + const fileService = accessor.get(IFileService); + const remoteAgentService = accessor.get(IRemoteAgentService); + const remoteEnvironment = await remoteAgentService.getEnvironment(); + if (remoteEnvironment?.os === OperatingSystem.Windows || !remoteEnvironment && isWindows) { + return undefined; + } + + /** + * From `fish` docs: + * > The command history is stored in the file ~/.local/share/fish/fish_history + * (or $XDG_DATA_HOME/fish/fish_history if that variable is set) by default. + * + * (https://fishshell.com/docs/current/interactive.html#history-search) + */ + const overridenDataHome = env['XDG_DATA_HOME']; + + // TODO: Unchecked fish behavior: + // What if XDG_DATA_HOME was defined but somehow $XDG_DATA_HOME/fish/fish_history + // was not exist. Does fish fall back to ~/.local/share/fish/fish_history? + + const content = await (overridenDataHome + ? fetchFileContents(env['XDG_DATA_HOME'], 'fish/fish_history', false, fileService, remoteAgentService) + : fetchFileContents(env['HOME'], '.local/share/fish/fish_history', false, fileService, remoteAgentService)); + if (content === undefined) { + return undefined; + } + + /** + * These apply to `fish` v3.5.1: + * - It looks like YAML but it's not. It's, quoting, *"a broken psuedo-YAML"*. + * See these discussions for more details: + * - https://github.com/fish-shell/fish-shell/pull/6493 + * - https://github.com/fish-shell/fish-shell/issues/3341 + * - Every record should exactly start with `- cmd:` (the whitespace between `-` and `cmd` cannot be replaced with tab) + * - Both `- cmd: echo 1` and `- cmd:echo 1` are valid entries. + * - Backslashes are esacped as `\\`. + * - Multiline commands are joined with a `\n` sequence, hence they're read as single line commands. + * - Property `when` is optional. + * - History navigation respects the records order and ignore the actual `when` property values (chronological order). + * - If `cmd` value is multiline , it just takes the first line. Also YAML operators like `>-` or `|-` are not supported. + */ + const result: Set = new Set(); + const cmds = content.split('\n') + .filter(x => x.startsWith('- cmd:')) + .map(x => x.substring(6).trimStart()); + for (let i = 0; i < cmds.length; i++) { + const sanitized = sanitizeFishHistoryCmd(cmds[i]).trim(); + if (sanitized.length > 0) { + result.add(sanitized); + } + } + return result.values(); +} + +export function sanitizeFishHistoryCmd(cmd: string): string { + /** + * NOTE + * This repeatedReplace() call can be eliminated by using look-ahead + * caluses in the original RegExp pattern: + * + * >>> ```ts + * >>> cmds[i].replace(/(?<=^|[^\\])((?:\\\\)*)(\\n)/g, '$1\n') + * >>> ``` + * + * But since not all browsers support look aheads we opted to a simple + * pattern and repeatedly calling replace method. + */ + return repeatedReplace(/(^|[^\\])((?:\\\\)*)(\\n)/g, cmd, '$1$2\n'); +} + +function repeatedReplace(pattern: RegExp, value: string, replaceValue: string): string { + let last; + let current = value; + while (true) { + last = current; + current = current.replace(pattern, replaceValue); + if (current === last) { + return current; + } + } +} + +async function fetchFileContents( + folderPrefix: string | undefined, + filePath: string, + isFileWindows: boolean, + fileService: Pick, + remoteAgentService: Pick, +): Promise { + if (!folderPrefix) { + return undefined; + } + const connection = remoteAgentService.getConnection(); + const isRemote = !!connection?.remoteAuthority; + const historyFileUri = URI.from({ + scheme: isRemote ? Schemas.vscodeRemote : Schemas.file, + authority: isRemote ? connection.remoteAuthority : undefined, + path: URI.file(join(folderPrefix, filePath)).path + }); + let content: IFileContent; + try { + content = await fileService.readFile(historyFileUri); + } catch (e: unknown) { + // Handle file not found only + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw e; + } + if (content === undefined) { + return undefined; + } + return content.value.toString(); +} diff --git a/src/vs/workbench/contrib/terminal/test/common/history.test.ts b/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts similarity index 93% rename from src/vs/workbench/contrib/terminal/test/common/history.test.ts rename to src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts index 185408cd10e..8f82cd2f709 100644 --- a/src/vs/workbench/contrib/terminal/test/common/history.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts @@ -4,22 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual, ok } from 'assert'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { join } from '../../../../../base/common/path.js'; -import { isWindows, OperatingSystem } from '../../../../../base/common/platform.js'; -import { env } from '../../../../../base/common/process.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; -import { fetchBashHistory, fetchFishHistory, fetchPwshHistory, fetchZshHistory, ITerminalPersistedHistory, sanitizeFishHistoryCmd, TerminalPersistedHistory } from '../../common/history.js'; -import { IRemoteAgentConnection, IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { join } from '../../../../../../base/common/path.js'; +import { isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; +import { env } from '../../../../../../base/common/process.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { ITerminalPersistedHistory, TerminalPersistedHistory } from '../../../../terminal/common/history.js'; +import { IRemoteAgentConnection, IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { fetchBashHistory, fetchFishHistory, fetchPwshHistory, fetchZshHistory, sanitizeFishHistoryCmd } from '../../common/history.js'; function getConfig(limit: number) { return {