diff --git a/src/vs/platform/files/common/diskFileSystemProviderClient.ts b/src/vs/platform/files/common/diskFileSystemProviderClient.ts index 9bd3dcb353b..da17f1145ff 100644 --- a/src/vs/platform/files/common/diskFileSystemProviderClient.ts +++ b/src/vs/platform/files/common/diskFileSystemProviderClient.ts @@ -56,7 +56,8 @@ export class DiskFileSystemProviderClient extends Disposable implements FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | - FileSystemProviderCapabilities.FileClone; + FileSystemProviderCapabilities.FileClone | + FileSystemProviderCapabilities.FileRealpath; if (this.extraCapabilities.pathCaseSensitive) { this._capabilities |= FileSystemProviderCapabilities.PathCaseSensitive; @@ -78,6 +79,10 @@ export class DiskFileSystemProviderClient extends Disposable implements return this.channel.call('stat', [resource]); } + realpath(resource: URI): Promise { + return this.channel.call('realpath', [resource]); + } + readdir(resource: URI): Promise<[string, FileType][]> { return this.channel.call('readdir', [resource]); } diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index 33666b18f45..a5f6dc5023a 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -18,7 +18,7 @@ import { extUri, extUriIgnorePathCase, IExtUri, isAbsolutePath } from '../../../ import { consumeStream, isReadableBufferedStream, isReadableStream, listenStream, newWriteableStream, peekReadable, peekStream, transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability, IWatchOptionsWithCorrelation, IFileSystemWatcher, IWatchOptionsWithoutCorrelation } from './files.js'; +import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability, IWatchOptionsWithCorrelation, IFileSystemWatcher, IWatchOptionsWithoutCorrelation, hasFileRealpathCapability } from './files.js'; import { readFileIntoStream } from './io.js'; import { ILogService } from '../../log/common/log.js'; import { ErrorNoTelemetry } from '../../../base/common/errors.js'; @@ -317,6 +317,18 @@ export class FileService extends Disposable implements IFileService { return this.toFileStat(provider, resource, stat, undefined, true, () => false /* Do not resolve any children */); } + async realpath(resource: URI): Promise { + const provider = await this.withProvider(resource); + + if (hasFileRealpathCapability(provider)) { + const realpath = await provider.realpath(resource); + + return resource.with({ path: realpath }); + } + + return undefined; + } + async exists(resource: URI): Promise { const provider = await this.withProvider(resource); diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index bec0de5c3f9..57e5c964484 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -133,6 +133,14 @@ export interface IFileService { */ stat(resource: URI): Promise; + /** + * Attempts to resolve the real path of the provided resource. The real path can be + * different from the resource path for example when it is a symlink. + * + * Will return `undefined` if the real path cannot be resolved. + */ + realpath(resource: URI): Promise; + /** * Finds out if a file/folder identified by the resource exists. */ @@ -635,7 +643,12 @@ export const enum FileSystemProviderCapabilities { /** * Provider support to clone files atomically. */ - FileClone = 1 << 17 + FileClone = 1 << 17, + + /** + * Provider support to resolve real paths. + */ + FileRealpath = 1 << 18 } export interface IFileSystemProvider { @@ -693,6 +706,14 @@ export function hasFileCloneCapability(provider: IFileSystemProvider): provider return !!(provider.capabilities & FileSystemProviderCapabilities.FileClone); } +export interface IFileSystemProviderWithFileRealpathCapability extends IFileSystemProvider { + realpath(resource: URI): Promise; +} + +export function hasFileRealpathCapability(provider: IFileSystemProvider): provider is IFileSystemProviderWithFileRealpathCapability { + return !!(provider.capabilities & FileSystemProviderCapabilities.FileRealpath); +} + export interface IFileSystemProviderWithOpenReadWriteCloseCapability extends IFileSystemProvider { open(resource: URI, opts: IFileOpenOptions): Promise; close(fd: number): Promise; diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index 1c54eac58c8..b2785c11ce2 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -18,7 +18,7 @@ import { newWriteableStream, ReadableStreamEvents } from '../../../base/common/s import { URI } from '../../../base/common/uri.js'; import { IDirent, Promises, RimRafMode, SymlinkSupport } from '../../../base/node/pfs.js'; import { localize } from '../../../nls.js'; -import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, isFileOpenForWriteOptions, IStat, FilePermission, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileAtomicDeleteCapability, IFileChange } from '../common/files.js'; +import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, isFileOpenForWriteOptions, IStat, FilePermission, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileAtomicDeleteCapability, IFileChange, IFileSystemProviderWithFileRealpathCapability } from '../common/files.js'; import { readFileIntoStream } from '../common/io.js'; import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, ILogMessage } from '../common/watcher.js'; import { ILogService } from '../../log/common/log.js'; @@ -34,7 +34,8 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileAtomicDeleteCapability, - IFileSystemProviderWithFileCloneCapability { + IFileSystemProviderWithFileCloneCapability, + IFileSystemProviderWithFileRealpathCapability { private static TRACE_LOG_RESOURCE_LOCKS = false; // not enabled by default because very spammy @@ -61,7 +62,8 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | - FileSystemProviderCapabilities.FileClone; + FileSystemProviderCapabilities.FileClone | + FileSystemProviderCapabilities.FileRealpath; if (isLinux) { this._capabilities |= FileSystemProviderCapabilities.PathCaseSensitive; @@ -99,6 +101,12 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple } } + async realpath(resource: URI): Promise { + const filePath = this.toFilePath(resource); + + return Promises.realpath(filePath); + } + async readdir(resource: URI): Promise<[string, FileType][]> { try { const children = await Promises.readdir(this.toFilePath(resource), { withFileTypes: true }); diff --git a/src/vs/platform/files/node/diskFileSystemProviderServer.ts b/src/vs/platform/files/node/diskFileSystemProviderServer.ts index e5a847eb635..1b7219ec10c 100644 --- a/src/vs/platform/files/node/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/node/diskFileSystemProviderServer.ts @@ -38,6 +38,7 @@ export abstract class AbstractDiskFileSystemProviderChannel extends Disposabl switch (command) { case 'stat': return this.stat(uriTransformer, arg[0]); + case 'realpath': return this.realpath(uriTransformer, arg[0]); case 'readdir': return this.readdir(uriTransformer, arg[0]); case 'open': return this.open(uriTransformer, arg[0], arg[1]); case 'close': return this.close(arg[0]); @@ -80,6 +81,12 @@ export abstract class AbstractDiskFileSystemProviderChannel extends Disposabl return this.provider.stat(resource); } + private realpath(uriTransformer: IURITransformer, _resource: UriComponents): Promise { + const resource = this.transformIncoming(uriTransformer, _resource, true); + + return this.provider.realpath(resource); + } + private readdir(uriTransformer: IURITransformer, _resource: UriComponents): Promise<[string, FileType][]> { const resource = this.transformIncoming(uriTransformer, _resource); diff --git a/src/vs/platform/files/test/node/diskFileService.integrationTest.ts b/src/vs/platform/files/test/node/diskFileService.integrationTest.ts index 555eb780087..4af8135ca7a 100644 --- a/src/vs/platform/files/test/node/diskFileService.integrationTest.ts +++ b/src/vs/platform/files/test/node/diskFileService.integrationTest.ts @@ -72,7 +72,8 @@ export class TestDiskFileSystemProvider extends DiskFileSystemProvider { FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | - FileSystemProviderCapabilities.FileClone; + FileSystemProviderCapabilities.FileClone | + FileSystemProviderCapabilities.FileRealpath; if (isLinux) { this._testCapabilities |= FileSystemProviderCapabilities.PathCaseSensitive; @@ -427,7 +428,7 @@ flakySuite('Disk File Service', function () { assert.strictEqual(r2.name, 'deep'); }); - test('resolve - folder symbolic link', async () => { + test('resolve / realpath - folder symbolic link', async () => { const link = URI.file(join(testDir, 'deep-link')); await promises.symlink(join(testDir, 'deep'), link.fsPath, 'junction'); @@ -435,6 +436,10 @@ flakySuite('Disk File Service', function () { assert.strictEqual(resolved.children!.length, 4); assert.strictEqual(resolved.isDirectory, true); assert.strictEqual(resolved.isSymbolicLink, true); + + const realpath = await service.realpath(link); + assert.ok(realpath); + assert.strictEqual(basename(realpath.fsPath), 'deep'); }); (isWindows ? test.skip /* windows: cannot create file symbolic link without elevated context */ : test)('resolve - file symbolic link', async () => { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index a9b9500381d..cdaddb94ea6 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -18,6 +18,7 @@ import { env as processEnv } from '../../../../../base/common/process.js'; import type { IProcessEnvironment } from '../../../../../base/common/platform.js'; import { timeout } from '../../../../../base/common/async.js'; import { gitBashToWindowsPath } from './terminalGitBashHelpers.js'; +import { isEqual } from '../../../../../base/common/resources.js'; export const ITerminalCompletionService = createDecorator('terminalCompletionService'); @@ -367,6 +368,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo // - (tilde) `cd ~/src/` -> `cd ~/src/folder1/`, ... for (const child of stat.children) { let kind: TerminalCompletionItemKind | undefined; + let detail: string | undefined = undefined; if (foldersRequested && child.isDirectory) { if (child.isSymbolicLink) { kind = TerminalCompletionItemKind.SymbolicLinkFolder; @@ -403,11 +405,23 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo } } + // Try to resolve symlink target for symbolic links + if (child.isSymbolicLink) { + try { + const realpath = await this._fileService.realpath(child.resource); + if (realpath && !isEqual(child.resource, realpath)) { + detail = `${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType)} -> ${getFriendlyPath(realpath, resourceRequestConfig.pathSeparator, kind, shellType)}`; + } + } catch (error) { + // Ignore errors resolving symlink targets - they may be dangling links + } + } + resourceCompletions.push({ label, provider, kind, - detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType), + detail: detail ?? getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType), replacementIndex: cursorPosition - lastWord.length, replacementLength: lastWord.length }); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts index 30ae35bb978..d271ac2486e 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts @@ -98,7 +98,7 @@ suite('TerminalCompletionService', () => { let configurationService: TestConfigurationService; let capabilities: TerminalCapabilityStore; let validResources: URI[]; - let childResources: { resource: URI; isFile?: boolean; isDirectory?: boolean }[]; + let childResources: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean }[]; let terminalCompletionService: TerminalCompletionService; const provider = 'testProvider'; @@ -122,8 +122,16 @@ suite('TerminalCompletionService', () => { count(childFsPath, '/') === count(parentFsPath, '/') + 1 ); }); - return createFileStat(resource, undefined, undefined, undefined, children); + return createFileStat(resource, undefined, undefined, undefined, undefined, children); }, + async realpath(resource: URI): Promise { + if (resource.path.includes('symlink-file')) { + return resource.with({ path: '/target/actual-file.txt' }); + } else if (resource.path.includes('symlink-folder')) { + return resource.with({ path: '/target/actual-folder' }); + } + return undefined; + } }); terminalCompletionService = store.add(instantiationService.createInstance(TerminalCompletionService)); terminalCompletionService.processEnv = testEnv; @@ -677,4 +685,36 @@ suite('TerminalCompletionService', () => { }); }); } + if (!isWindows) { + suite('symlink support', () => { + test('should include symlink target information in completions', async () => { + const resourceRequestConfig: TerminalResourceRequestConfig = { + cwd: URI.parse('file:///test'), + pathSeparator, + filesRequested: true, + foldersRequested: true + }; + + validResources = [URI.parse('file:///test')]; + + // Create mock children including a symbolic link + childResources = [ + { resource: URI.parse('file:///test/regular-file.txt'), isFile: true }, + { resource: URI.parse('file:///test/symlink-file'), isFile: true, isSymbolicLink: true }, + { resource: URI.parse('file:///test/symlink-folder'), isDirectory: true, isSymbolicLink: true }, + { resource: URI.parse('file:///test/regular-folder'), isDirectory: true }, + ]; + + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'ls ', 3, provider, capabilities); + + // Find the symlink completion + const symlinkFileCompletion = result?.find(c => c.label === './symlink-file'); + const symlinkFolderCompletion = result?.find(c => c.label === './symlink-folder/'); + assert.strictEqual(symlinkFileCompletion?.detail, '/test/symlink-file -> /target/actual-file.txt', 'Symlink file detail should match target'); + assert.strictEqual(symlinkFolderCompletion?.detail, '/test/symlink-folder -> /target/actual-folder', 'Symlink folder detail should match target'); + }); + }); + } }); + + diff --git a/src/vs/workbench/services/files/electron-browser/diskFileSystemProvider.ts b/src/vs/workbench/services/files/electron-browser/diskFileSystemProvider.ts index 5c0af31e6b3..2227a3ac111 100644 --- a/src/vs/workbench/services/files/electron-browser/diskFileSystemProvider.ts +++ b/src/vs/workbench/services/files/electron-browser/diskFileSystemProvider.ts @@ -68,6 +68,10 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple return this.provider.stat(resource); } + realpath(resource: URI): Promise { + return this.provider.realpath(resource); + } + readdir(resource: URI): Promise<[string, FileType][]> { return this.provider.readdir(resource); } diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index b9f1daec9db..8793027ee3c 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1120,6 +1120,10 @@ export class TestFileService implements IFileService { return this.resolve(resource, { resolveMetadata: true }); } + async realpath(resource: URI): Promise { + return resource; + } + async resolveAll(toResolve: { resource: URI; options?: IResolveFileOptions }[]): Promise { const stats = await Promise.all(toResolve.map(resourceAndOption => this.resolve(resourceAndOption.resource, resourceAndOption.options))); diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index eac375577dc..5820e0a9c27 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -226,7 +226,7 @@ export class TestWorkingCopy extends Disposable implements IWorkingCopy { } } -export function createFileStat(resource: URI, readonly = false, isFile?: boolean, isDirectory?: boolean, children?: { resource: URI; isFile?: boolean; isDirectory?: boolean }[] | undefined): IFileStatWithMetadata { +export function createFileStat(resource: URI, readonly = false, isFile?: boolean, isDirectory?: boolean, isSymbolicLink?: boolean, children?: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean }[] | undefined): IFileStatWithMetadata { return { resource, etag: Date.now().toString(), @@ -235,11 +235,11 @@ export function createFileStat(resource: URI, readonly = false, isFile?: boolean size: 42, isFile: isFile ?? true, isDirectory: isDirectory ?? false, - isSymbolicLink: false, + isSymbolicLink: isSymbolicLink ?? false, readonly, locked: false, name: basename(resource), - children: children?.map(c => createFileStat(c.resource, false, c.isFile, c.isDirectory)) + children: children?.map(c => createFileStat(c.resource, false, c.isFile, c.isDirectory, c.isSymbolicLink)), }; }