diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index de55c4d52b6..d479aa32a77 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1039,6 +1039,12 @@ export interface ITerminalInstance extends IBaseTerminalInstance { */ preparePathForShell(originalPath: string): Promise; + /** + * 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; + /** 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; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 99c6d385c37..3168e53a12c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -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 { + // 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; diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index 8e42cecb139..a0e95b1b934 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -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 | undefined, os: OperatingSystem | undefined, isWindowsFrontend: boolean = isWindows): Promise { 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, shellType?: TerminalShellType, os?: OperatingSystem, isWindowsFrontend: boolean = isWindows): Promise { + 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. diff --git a/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts b/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts index 79796d480fe..dad61911059 100644 --- a/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts +++ b/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts @@ -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\/(?[a-zA-Z])\/(?.+)$/); + const groups = match?.groups; + if (!groups) { + return original; + } + return `${groups.drive}:\\${groups.path.replace(/\//g, '\\')}`; + } + const match = original.match(/(?[a-zA-Z]):\\(?.+)/); + 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\/(?[a-zA-Z])\/(?.+)$/); - const groups = match?.groups; - if (!groups) { - return original; - } - return `${groups.drive}:\\${groups.path.replace(/\//g, '\\')}`; - } - const match = original.match(/(?[a-zA-Z]):\\(?.+)/); - 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'); + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts b/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts index be6fde5fe2a..51cd32ab0dd 100644 --- a/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts +++ b/src/vs/workbench/contrib/terminalContrib/history/browser/terminalRunRecentQuickPick.ts @@ -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;