Deduplicate and format entries in terminal's Go To quick pick (#273008)

* Deduplicate and format items in terminal's Go To quick pick

* Add unit-tests

* PR feedback, add missing unit-tests

* PR feedback
This commit is contained in:
Dmitriy Vasyura
2025-10-25 02:22:16 -07:00
committed by GitHub
parent baf06734a5
commit d06aeffbcb
5 changed files with 112 additions and 31 deletions

View File

@@ -1039,6 +1039,12 @@ export interface ITerminalInstance extends IBaseTerminalInstance {
*/
preparePathForShell(originalPath: string): Promise<string>;
/**
* Formats a file system URI for display in UI so that it appears in the terminal shell's format.
* @param uri The URI to format.
*/
getUriLabelForShell(uri: URI): Promise<string>;
/** Scroll the terminal buffer down 1 line. */ scrollDownLine(): void;
/** Scroll the terminal buffer down 1 page. */ scrollDownPage(): void;
/** Scroll the terminal buffer to the bottom. */ scrollToBottom(): void;

View File

@@ -72,7 +72,7 @@ import { IEnvironmentVariableInfo } from '../common/environmentVariable.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';
import { getShellIntegrationTimeout, getWorkspaceForTerminal, preparePathForShell } from '../common/terminalEnvironment.js';
import { getUriLabelForShell, getShellIntegrationTimeout, getWorkspaceForTerminal, preparePathForShell } from '../common/terminalEnvironment.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { IHistoryService } from '../../../services/history/common/history.js';
@@ -1343,6 +1343,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
return preparePathForShell(originalPath, this.shellLaunchConfig.executable, this.title, this.shellType, this._processManager.backend, this._processManager.os);
}
async getUriLabelForShell(uri: URI): Promise<string> {
// Wait for shell type to be ready
await this.processReady;
return getUriLabelForShell(uri, this._processManager.backend!, this.shellType, this.os);
}
setVisible(visible: boolean): void {
const didChange = this._isVisible !== visible;
this._isVisible = visible;

View File

@@ -8,7 +8,7 @@
*/
import * as path from '../../../../base/common/path.js';
import { URI } from '../../../../base/common/uri.js';
import { URI, uriToFsPath } from '../../../../base/common/uri.js';
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';
import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js';
import { sanitizeProcessEnvironment } from '../../../../base/common/processes.js';
@@ -315,7 +315,7 @@ export async function createTerminalEnvironment(
* @param backend The backend for the terminal.
* @param isWindowsFrontend Whether the frontend is Windows, this is only exposed for injection via
* tests.
* @returns An escaped version of the path to be execuded in the terminal.
* @returns An escaped version of the path to be executed in the terminal.
*/
export async function preparePathForShell(resource: string | URI, executable: string | undefined, title: string, shellType: TerminalShellType | undefined, backend: Pick<ITerminalBackend, 'getWslPath'> | undefined, os: OperatingSystem | undefined, isWindowsFrontend: boolean = isWindows): Promise<string> {
let originalPath: string;
@@ -344,7 +344,6 @@ export async function preparePathForShell(resource: string | URI, executable: st
pathBasename === 'powershell' ||
title === 'powershell';
if (isPowerShell && (hasSpace || originalPath.includes('\''))) {
return `& '${originalPath.replace(/'/g, '\'\'')}'`;
}
@@ -392,6 +391,25 @@ export function getWorkspaceForTerminal(cwd: URI | string | undefined, workspace
return workspaceFolder;
}
export async function getUriLabelForShell(uri: URI | string, backend: Pick<ITerminalBackend, 'getWslPath'>, shellType?: TerminalShellType, os?: OperatingSystem, isWindowsFrontend: boolean = isWindows): Promise<string> {
let path = typeof uri === 'string' ? uri : uri.fsPath;
if (os === OperatingSystem.Windows) {
if (shellType === WindowsShellType.Wsl) {
return backend.getWslPath(path.replaceAll('/', '\\'), 'win-to-unix');
} else if (shellType === WindowsShellType.GitBash) {
// Convert \ to / and replace 'c:\' with '/c/'.
return path.replaceAll('\\', '/').replace(/^([a-zA-Z]):\//, '/$1/');
} else {
// If the frontend is not Windows but the terminal is, convert / to \.
path = typeof uri === 'string' ? path : uriToFsPath(uri, true);
return !isWindowsFrontend ? path.replaceAll('/', '\\') : path;
}
} else {
// If the frontend is Windows but the terminal is not, convert \ to /.
return isWindowsFrontend ? path.replaceAll('\\', '/') : path;
}
}
/**
* Gets the unified duration to wait for shell integration after the terminal launches before
* declaring the terminal lacks shell integration.

View File

@@ -7,10 +7,29 @@ import { deepStrictEqual, strictEqual } from 'assert';
import { IStringDictionary } from '../../../../../base/common/collections.js';
import { isWindows, OperatingSystem } from '../../../../../base/common/platform.js';
import { URI as Uri } from '../../../../../base/common/uri.js';
import { addTerminalEnvironmentKeys, createTerminalEnvironment, getCwd, getLangEnvVariable, mergeEnvironments, preparePathForShell, shouldSetLangEnvVariable } from '../../common/terminalEnvironment.js';
import { addTerminalEnvironmentKeys, createTerminalEnvironment, getUriLabelForShell, getCwd, getLangEnvVariable, mergeEnvironments, preparePathForShell, shouldSetLangEnvVariable } from '../../common/terminalEnvironment.js';
import { GeneralShellType, PosixShellType, WindowsShellType } from '../../../../../platform/terminal/common/terminal.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
const wslPathBackend = {
getWslPath: async (original: string, direction: 'unix-to-win' | 'win-to-unix') => {
if (direction === 'unix-to-win') {
const match = original.match(/^\/mnt\/(?<drive>[a-zA-Z])\/(?<path>.+)$/);
const groups = match?.groups;
if (!groups) {
return original;
}
return `${groups.drive}:\\${groups.path.replace(/\//g, '\\')}`;
}
const match = original.match(/(?<drive>[a-zA-Z]):\\(?<path>.+)/);
const groups = match?.groups;
if (!groups) {
return original;
}
return `/mnt/${groups.drive.toLowerCase()}/${groups.path.replace(/\\/g, '/')}`;
}
};
suite('Workbench - TerminalEnvironment', () => {
ensureNoDisposablesAreLeakedInTestSuite();
@@ -214,24 +233,6 @@ suite('Workbench - TerminalEnvironment', () => {
});
suite('preparePathForShell', () => {
const wslPathBackend = {
getWslPath: async (original: string, direction: 'unix-to-win' | 'win-to-unix') => {
if (direction === 'unix-to-win') {
const match = original.match(/^\/mnt\/(?<drive>[a-zA-Z])\/(?<path>.+)$/);
const groups = match?.groups;
if (!groups) {
return original;
}
return `${groups.drive}:\\${groups.path.replace(/\//g, '\\')}`;
}
const match = original.match(/(?<drive>[a-zA-Z]):\\(?<path>.+)/);
const groups = match?.groups;
if (!groups) {
return original;
}
return `/mnt/${groups.drive.toLowerCase()}/${groups.path.replace(/\\/g, '/')}`;
}
};
suite('Windows frontend, Windows backend', () => {
test('Command Prompt', async () => {
strictEqual(await preparePathForShell('c:\\foo\\bar', 'cmd', 'cmd', WindowsShellType.CommandPrompt, wslPathBackend, OperatingSystem.Windows, true), `c:\\foo\\bar`);
@@ -319,4 +320,34 @@ suite('Workbench - TerminalEnvironment', () => {
);
});
});
suite('formatUriForShellDisplay', () => {
test('Wsl', async () => {
strictEqual(await getUriLabelForShell('c:\\foo\\bar', wslPathBackend, WindowsShellType.Wsl, OperatingSystem.Windows, true), '/mnt/c/foo/bar');
strictEqual(await getUriLabelForShell('c:/foo/bar', wslPathBackend, WindowsShellType.Wsl, OperatingSystem.Windows, false), '/mnt/c/foo/bar');
});
test('GitBash', async () => {
strictEqual(await getUriLabelForShell('c:\\foo\\bar', wslPathBackend, WindowsShellType.GitBash, OperatingSystem.Windows, true), '/c/foo/bar');
strictEqual(await getUriLabelForShell('c:/foo/bar', wslPathBackend, WindowsShellType.GitBash, OperatingSystem.Windows, false), '/c/foo/bar');
});
suite('PowerShell', () => {
test('Windows frontend', async () => {
strictEqual(await getUriLabelForShell('c:\\foo\\bar', wslPathBackend, GeneralShellType.PowerShell, OperatingSystem.Windows, true), 'c:\\foo\\bar');
strictEqual(await getUriLabelForShell('C:\\Foo\\Bar', wslPathBackend, GeneralShellType.PowerShell, OperatingSystem.Windows, true), 'C:\\Foo\\Bar');
});
test('Non-Windows frontend', async () => {
strictEqual(await getUriLabelForShell('c:/foo/bar', wslPathBackend, GeneralShellType.PowerShell, OperatingSystem.Windows, false), 'c:\\foo\\bar');
strictEqual(await getUriLabelForShell('C:/Foo/Bar', wslPathBackend, GeneralShellType.PowerShell, OperatingSystem.Windows, false), 'C:\\Foo\\Bar');
});
});
suite('Bash', () => {
test('Windows frontend', async () => {
strictEqual(await getUriLabelForShell('\\foo\\bar', wslPathBackend, PosixShellType.Bash, OperatingSystem.Linux, true), '/foo/bar');
strictEqual(await getUriLabelForShell('/foo/bar', wslPathBackend, PosixShellType.Bash, OperatingSystem.Linux, true), '/foo/bar');
});
test('Non-Windows frontend', async () => {
strictEqual(await getUriLabelForShell('\\foo\\bar', wslPathBackend, PosixShellType.Bash, OperatingSystem.Linux, false), '\\foo\\bar');
strictEqual(await getUriLabelForShell('/foo/bar', wslPathBackend, PosixShellType.Bash, OperatingSystem.Linux, false), '/foo/bar');
});
});
});
});

View File

@@ -28,6 +28,9 @@ import { IContextKey } from '../../../../../platform/contextkey/common/contextke
import { AccessibleViewProviderId, IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js';
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { getCommandHistory, getDirectoryHistory, getShellFileHistory } from '../common/history.js';
import { ResourceSet } from '../../../../../base/common/map.js';
import { extUri, extUriIgnorePathCase } from '../../../../../base/common/resources.js';
import { IPathService } from '../../../../services/path/common/pathService.js';
export async function showRunRecentQuickPick(
accessor: ServicesAccessor,
@@ -46,6 +49,7 @@ export async function showRunRecentQuickPick(
const instantiationService = accessor.get(IInstantiationService);
const quickInputService = accessor.get(IQuickInputService);
const storageService = accessor.get(IStorageService);
const pathService = accessor.get(IPathService);
const runRecentStorageKey = `${TerminalStorageKeys.PinnedRecentCommandsPrefix}.${instance.shellType}`;
let placeholder: string;
@@ -198,10 +202,22 @@ export async function showRunRecentQuickPick(
placeholder = isMacintosh
? localize('selectRecentDirectoryMac', 'Select a directory to go to (hold Option-key to edit the command)')
: localize('selectRecentDirectory', 'Select a directory to go to (hold Alt-key to edit the command)');
// Check path uniqueness following target platform's case sensitivity rules.
const uriComparer = instance.os === OperatingSystem.Windows ? extUriIgnorePathCase : extUri;
const uniqueUris = new ResourceSet(o => uriComparer.getComparisonKey(o));
const cwds = instance.capabilities.get(TerminalCapability.CwdDetection)?.cwds || [];
if (cwds && cwds.length > 0) {
for (const label of cwds) {
items.push({ label, rawLabel: label });
const itemUri = URI.file(label);
if (!uniqueUris.has(itemUri)) {
uniqueUris.add(itemUri);
items.push({
label: await instance.getUriLabelForShell(itemUri),
rawLabel: label
});
}
}
items = items.reverse();
items.unshift({ type: 'separator', label: terminalStrings.currentSessionCategory });
@@ -212,12 +228,16 @@ export async function showRunRecentQuickPick(
const previousSessionItems: (IQuickPickItem & { rawLabel: string })[] = [];
// Only add previous session item if it's not in this session and it matches the remote authority
for (const [label, info] of history.entries) {
if ((info === null || info.remoteAuthority === instance.remoteAuthority) && !cwds.includes(label)) {
previousSessionItems.unshift({
label,
rawLabel: label,
buttons: [removeFromCommandHistoryButton]
});
if (info === null || info.remoteAuthority === instance.remoteAuthority) {
const itemUri = info?.remoteAuthority ? await pathService.fileURI(label) : URI.file(label);
if (!uniqueUris.has(itemUri)) {
uniqueUris.add(itemUri);
previousSessionItems.unshift({
label: await instance.getUriLabelForShell(itemUri),
rawLabel: label,
buttons: [removeFromCommandHistoryButton]
});
}
}
}
if (previousSessionItems.length > 0) {
@@ -255,7 +275,7 @@ export async function showRunRecentQuickPick(
if (type === 'command') {
instantiationService.invokeFunction(getCommandHistory)?.remove(e.item.label);
} else {
instantiationService.invokeFunction(getDirectoryHistory)?.remove(e.item.label);
instantiationService.invokeFunction(getDirectoryHistory)?.remove(e.item.rawLabel);
}
} else if (e.button === commandOutputButton) {
const selectedCommand = (e.item as Item).command;