files - allow to resolve `realpath` and adopt (#251690)

This commit is contained in:
Benjamin Pasero 2025-06-17 10:45:34 +02:00 committed by GitHub
parent 2cd6a151f5
commit d59fac320f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 134 additions and 14 deletions

View File

@ -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<string> {
return this.channel.call('realpath', [resource]);
}
readdir(resource: URI): Promise<[string, FileType][]> {
return this.channel.call('readdir', [resource]);
}

View File

@ -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<URI | undefined> {
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<boolean> {
const provider = await this.withProvider(resource);

View File

@ -133,6 +133,14 @@ export interface IFileService {
*/
stat(resource: URI): Promise<IFileStatWithPartialMetadata>;
/**
* 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<URI | undefined>;
/**
* 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<string>;
}
export function hasFileRealpathCapability(provider: IFileSystemProvider): provider is IFileSystemProviderWithFileRealpathCapability {
return !!(provider.capabilities & FileSystemProviderCapabilities.FileRealpath);
}
export interface IFileSystemProviderWithOpenReadWriteCloseCapability extends IFileSystemProvider {
open(resource: URI, opts: IFileOpenOptions): Promise<number>;
close(fd: number): Promise<void>;

View File

@ -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<string> {
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 });

View File

@ -38,6 +38,7 @@ export abstract class AbstractDiskFileSystemProviderChannel<T> 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<T> extends Disposabl
return this.provider.stat(resource);
}
private realpath(uriTransformer: IURITransformer, _resource: UriComponents): Promise<string> {
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);

View File

@ -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 () => {

View File

@ -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<ITerminalCompletionService>('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
});

View File

@ -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<URI | undefined> {
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');
});
});
}
});

View File

@ -68,6 +68,10 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple
return this.provider.stat(resource);
}
realpath(resource: URI): Promise<string> {
return this.provider.realpath(resource);
}
readdir(resource: URI): Promise<[string, FileType][]> {
return this.provider.readdir(resource);
}

View File

@ -1120,6 +1120,10 @@ export class TestFileService implements IFileService {
return this.resolve(resource, { resolveMetadata: true });
}
async realpath(resource: URI): Promise<URI> {
return resource;
}
async resolveAll(toResolve: { resource: URI; options?: IResolveFileOptions }[]): Promise<IFileStatResult[]> {
const stats = await Promise.all(toResolve.map(resourceAndOption => this.resolve(resourceAndOption.resource, resourceAndOption.options)));

View File

@ -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)),
};
}