Skip to content

Commit b4f27b8

Browse files
author
Benjamin Pasero
committed
files2 - implement basic copy of files without copy support via unbuffered solution
1 parent fa854de commit b4f27b8

5 files changed

Lines changed: 221 additions & 49 deletions

File tree

src/vs/base/common/async.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,3 +765,19 @@ export class IdleValue<T> {
765765
}
766766

767767
//#endregion
768+
769+
export async function retry<T>(task: ITask<Promise<T>>, delay: number, retries: number): Promise<T> {
770+
let lastError: Error | undefined;
771+
772+
for (let i = 0; i < retries; i++) {
773+
try {
774+
return await task();
775+
} catch (error) {
776+
lastError = error;
777+
778+
await timeout(delay);
779+
}
780+
}
781+
782+
return Promise.reject(lastError);
783+
}

src/vs/base/test/common/async.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,4 +531,30 @@ suite('Async', () => {
531531
assert.notEqual(r1Queue, r1Queue2); // previous one got disposed after finishing
532532
});
533533
});
534+
535+
test('retry - success case', async () => {
536+
let counter = 0;
537+
538+
const res = await async.retry(() => {
539+
counter++;
540+
if (counter < 2) {
541+
return Promise.reject(new Error('fail'));
542+
}
543+
544+
return Promise.resolve(true);
545+
}, 10, 3);
546+
547+
assert.equal(res, true);
548+
});
549+
550+
test('retry - error case', async () => {
551+
let expectedError = new Error('fail');
552+
try {
553+
await async.retry(() => {
554+
return Promise.reject(expectedError);
555+
}, 10, 3);
556+
} catch (error) {
557+
assert.equal(error, error);
558+
}
559+
});
534560
});

src/vs/platform/files/common/files.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,34 @@ export interface IFileSystemProvider {
243243
write?(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number>;
244244
}
245245

246+
export interface IFileSystemProviderWithFileReadWriteCapability extends IFileSystemProvider {
247+
readFile(resource: URI): Promise<Uint8Array>;
248+
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void>;
249+
}
250+
251+
export function hasReadWriteCapability(provider: IFileSystemProvider): provider is IFileSystemProviderWithFileReadWriteCapability {
252+
return !!(provider.capabilities & FileSystemProviderCapabilities.FileReadWrite);
253+
}
254+
255+
export interface IFileSystemProviderWithFileFolderCopyCapability extends IFileSystemProvider {
256+
copy(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void>;
257+
}
258+
259+
export function hasFileFolderCopyCapability(provider: IFileSystemProvider): provider is IFileSystemProviderWithFileFolderCopyCapability {
260+
return !!(provider.capabilities & FileSystemProviderCapabilities.FileFolderCopy);
261+
}
262+
263+
export interface IFileSystemProviderWithOpenReadWriteCloseCapability extends IFileSystemProvider {
264+
open(resource: URI, opts: FileOpenOptions): Promise<number>;
265+
close(fd: number): Promise<void>;
266+
read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number>;
267+
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number>;
268+
}
269+
270+
export function hasOpenReadWriteCloseCapability(provider: IFileSystemProvider): provider is IFileSystemProviderWithOpenReadWriteCloseCapability {
271+
return !!(provider.capabilities & FileSystemProviderCapabilities.FileOpenReadWriteClose);
272+
}
273+
246274
export enum FileSystemProviderErrorCode {
247275
FileExists = 'EntryExists',
248276
FileNotFound = 'EntryNotFound',

src/vs/workbench/services/files2/common/fileService2.ts

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Disposable, IDisposable, toDisposable, combinedDisposable } from 'vs/base/common/lifecycle';
7-
import { IFileService, IResolveFileOptions, IResourceEncodings, FileChangesEvent, FileOperationEvent, IFileSystemProviderRegistrationEvent, IFileSystemProvider, IFileStat, IResolveFileResult, IResolveContentOptions, IContent, IStreamContent, ITextSnapshot, IUpdateContentOptions, ICreateFileOptions, IFileSystemProviderActivationEvent, FileOperationError, FileOperationResult, FileOperation, FileSystemProviderCapabilities, FileType, toFileSystemProviderErrorCode, FileSystemProviderErrorCode, IStat, IFileStatWithMetadata, IResolveMetadataFileOptions, etag } from 'vs/platform/files/common/files';
7+
import { IFileService, IResolveFileOptions, IResourceEncodings, FileChangesEvent, FileOperationEvent, IFileSystemProviderRegistrationEvent, IFileSystemProvider, IFileStat, IResolveFileResult, IResolveContentOptions, IContent, IStreamContent, ITextSnapshot, IUpdateContentOptions, ICreateFileOptions, IFileSystemProviderActivationEvent, FileOperationError, FileOperationResult, FileOperation, FileSystemProviderCapabilities, FileType, toFileSystemProviderErrorCode, FileSystemProviderErrorCode, IStat, IFileStatWithMetadata, IResolveMetadataFileOptions, etag, hasReadWriteCapability, hasFileFolderCopyCapability, hasOpenReadWriteCloseCapability } from 'vs/platform/files/common/files';
88
import { URI } from 'vs/base/common/uri';
99
import { Event, Emitter } from 'vs/base/common/event';
1010
import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation';
@@ -223,7 +223,7 @@ export class FileService2 extends Disposable implements IFileService {
223223
const childResource = joinPath(resource, name);
224224
const childStat = resolveMetadata ? await provider.stat(childResource) : { type };
225225

226-
return this.toFileStat(provider, childResource, childStat, entries.length, resolveMetadata, recurse);
226+
return await this.toFileStat(provider, childResource, childStat, entries.length, resolveMetadata, recurse);
227227
} catch (error) {
228228
this.logService.trace(error);
229229

@@ -322,21 +322,19 @@ export class FileService2 extends Disposable implements IFileService {
322322

323323
//#region Move/Copy/Delete/Create Folder
324324

325-
moveFile(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
326-
if (source.scheme === target.scheme) {
327-
return this.doMoveCopyWithSameProvider(source, target, false /* just move */, overwrite);
328-
}
329-
330-
return this.doMoveWithDifferentProvider(source, target);
331-
}
325+
async moveFile(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
332326

333-
private async doMoveWithDifferentProvider(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
327+
// same provider
328+
if (source.scheme === target.scheme) {
329+
const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(source));
334330

335-
// copy file source => target
336-
await this.copyFile(source, target, overwrite);
331+
await this.doMoveCopy(provider, source, target, false /* just move */, overwrite);
332+
}
337333

338-
// delete source
339-
await this.del(source, { recursive: true });
334+
// across providers
335+
else {
336+
await this.doMoveAcrossProviders(source, target);
337+
}
340338

341339
// resolve and send events
342340
const fileStat = await this.resolveFile(target, { resolveMetadata: true });
@@ -345,33 +343,47 @@ export class FileService2 extends Disposable implements IFileService {
345343
return fileStat;
346344
}
347345

348-
async copyFile(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
349-
if (source.scheme === target.scheme) {
350-
return this.doCopyWithSameProvider(source, target, overwrite);
346+
private async doMoveCopy(provider: IFileSystemProvider, source: URI, target: URI, keepCopy: boolean, overwrite?: boolean): Promise<void> {
347+
348+
// validation
349+
const { exists, isCaseChange } = await this.doValidateMoveCopy(provider, source, target, keepCopy, overwrite);
350+
351+
// delete as needed
352+
if (exists && !isCaseChange) {
353+
await this.del(target, { recursive: true });
351354
}
352355

353-
return this.doCopyWithDifferentProvider(source, target);
354-
}
356+
// create parent folders
357+
await this.mkdirp(provider, dirname(target));
358+
359+
// rename/copy source => target
360+
if (keepCopy) {
355361

356-
private async doCopyWithSameProvider(source: URI, target: URI, overwrite: boolean = false): Promise<IFileStatWithMetadata> {
357-
const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(source));
362+
// check if provider supports fast file/folder copy
363+
if (hasFileFolderCopyCapability(provider)) {
364+
return provider.copy(source, target, { overwrite: !!overwrite });
365+
}
358366

359-
// check if provider supports fast file/folder copy
360-
if (provider.capabilities & FileSystemProviderCapabilities.FileFolderCopy && typeof provider.copy === 'function') {
361-
return this.doMoveCopyWithSameProvider(source, target, true /* keep copy */, overwrite);
362-
}
367+
// otherwise we need to manually copy: via read/write
368+
if (hasOpenReadWriteCloseCapability(provider)) {
369+
return this.joinOnLegacy.then(legacy => legacy.copyFile(source, target, overwrite)).then(() => undefined);
370+
}
363371

364-
return this.joinOnLegacy.then(legacy => legacy.copyFile(source, target, overwrite));
365-
}
372+
// otherwise we need to manually copy: via readFile/writeFile
373+
if (hasReadWriteCapability(provider)) {
374+
return provider.writeFile(target, await provider.readFile(source), { create: true, overwrite: !!overwrite });
375+
}
366376

367-
private async doCopyWithDifferentProvider(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
368-
return this.joinOnLegacy.then(legacy => legacy.copyFile(source, target, overwrite));
377+
// give up if provider has insufficient capabilities
378+
return Promise.reject('Provider neither has FileReadWrite nor FileOpenReadWriteClose capability which is needed to support copy.');
379+
} else {
380+
return provider.rename(source, target, { overwrite: !!overwrite });
381+
}
369382
}
370383

371-
private async doMoveCopyWithSameProvider(source: URI, target: URI, keepCopy: boolean, overwrite?: boolean): Promise<IFileStatWithMetadata> {
372-
const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(source));
384+
private async doValidateMoveCopy(provider: IFileSystemProvider, source: URI, target: URI, keepCopy: boolean, overwrite?: boolean): Promise<{ exists: boolean, isCaseChange: boolean }> {
373385

374-
// validation
386+
// Check if source is equal or parent to target
375387
const isPathCaseSensitive = !!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
376388
const isCaseChange = isPathCaseSensitive ? false : isEqual(source, target, true /* ignore case */);
377389
if (!isCaseChange && isEqualOrParent(target, source, !isPathCaseSensitive)) {
@@ -380,6 +392,8 @@ export class FileService2 extends Disposable implements IFileService {
380392

381393
const exists = await this.existsFile(target);
382394
if (exists && !isCaseChange) {
395+
396+
// Bail out if target exists and we are not about to overwrite
383397
if (!overwrite) {
384398
throw new FileOperationError(localize('unableToMoveCopyError2', "Unable to move/copy. File already exists at destination."), FileOperationResult.FILE_MOVE_CONFLICT);
385399
}
@@ -389,27 +403,45 @@ export class FileService2 extends Disposable implements IFileService {
389403
if (isEqualOrParent(source, target, !isPathCaseSensitive)) {
390404
return Promise.reject(new Error(localize('unableToMoveCopyError3', "Unable to move/copy. File would replace folder it is contained in.")));
391405
}
392-
393-
await this.del(target, { recursive: true });
394406
}
395407

396-
// create parent folders
397-
await this.mkdirp(provider, dirname(target));
408+
return { exists, isCaseChange };
409+
}
398410

399-
// rename/copy source => target
400-
if (keepCopy) {
401-
await provider.copy!(source, target, { overwrite: !!overwrite });
402-
} else {
403-
await provider.rename(source, target, { overwrite: !!overwrite });
411+
private async doMoveAcrossProviders(source: URI, target: URI, overwrite?: boolean): Promise<void> {
412+
413+
// copy file source => target
414+
await this.copyFile(source, target, overwrite);
415+
416+
// delete source
417+
await this.del(source, { recursive: true });
418+
}
419+
420+
async copyFile(source: URI, target: URI, overwrite?: boolean): Promise<IFileStatWithMetadata> {
421+
422+
// same provider
423+
if (source.scheme === target.scheme) {
424+
const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(source));
425+
426+
await this.doMoveCopy(provider, source, target, true /* mode: copy */, overwrite);
427+
}
428+
429+
// across providers
430+
else {
431+
await this.doCopyAcrossProviders(source, target);
404432
}
405433

406434
// resolve and send events
407435
const fileStat = await this.resolveFile(target, { resolveMetadata: true });
408-
this._onAfterOperation.fire(new FileOperationEvent(source, keepCopy ? FileOperation.COPY : FileOperation.MOVE, fileStat));
436+
this._onAfterOperation.fire(new FileOperationEvent(source, FileOperation.COPY, fileStat));
409437

410438
return fileStat;
411439
}
412440

441+
private async doCopyAcrossProviders(source: URI, target: URI, overwrite?: boolean): Promise<void> {
442+
return this.joinOnLegacy.then(legacy => legacy.copyFile(source, target, overwrite)).then(() => undefined);
443+
}
444+
413445
async createFolder(resource: URI): Promise<IFileStatWithMetadata> {
414446
const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource));
415447

src/vs/workbench/services/files2/node/diskFileSystemProvider.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { IDisposable, Disposable } from 'vs/base/common/lifecycle';
1010
import { IFileSystemProvider, FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, FileDeleteOptions, FileOverwriteOptions, FileWriteOptions, FileOpenOptions, FileSystemProviderErrorCode, createFileSystemProviderError, FileSystemProviderError } from 'vs/platform/files/common/files';
1111
import { URI } from 'vs/base/common/uri';
1212
import { Event, Emitter } from 'vs/base/common/event';
13-
import { isLinux } from 'vs/base/common/platform';
14-
import { statLink, readdir, unlink, del, move, copy } from 'vs/base/node/pfs';
13+
import { isLinux, isWindows } from 'vs/base/common/platform';
14+
import { statLink, readdir, unlink, del, move, copy, readFile, writeFile, fileExists, truncate } from 'vs/base/node/pfs';
1515
import { normalize } from 'vs/base/common/path';
1616
import { joinPath } from 'vs/base/common/resources';
17+
import { isEqual } from 'vs/base/common/extpath';
18+
import { retry } from 'vs/base/common/async';
1719

1820
export class DiskFileSystemProvider extends Disposable implements IFileSystemProvider {
1921

@@ -78,12 +80,55 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro
7880

7981
//#region File Reading/Writing
8082

81-
readFile(resource: URI): Promise<Uint8Array> {
82-
throw new Error('Method not implemented.');
83+
async readFile(resource: URI): Promise<Uint8Array> {
84+
try {
85+
const filePath = this.toFilePath(resource);
86+
87+
return await readFile(filePath);
88+
} catch (error) {
89+
throw this.toFileSystemProviderError(error);
90+
}
8391
}
8492

85-
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
86-
throw new Error('Method not implemented.');
93+
async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
94+
try {
95+
const filePath = this.toFilePath(resource);
96+
97+
// Validate target
98+
const exists = await fileExists(filePath);
99+
if (exists && !opts.overwrite) {
100+
throw createFileSystemProviderError(new Error('File already exists'), FileSystemProviderErrorCode.FileExists);
101+
} else if (!exists && !opts.create) {
102+
throw createFileSystemProviderError(new Error('File does not exist'), FileSystemProviderErrorCode.FileNotFound);
103+
}
104+
105+
if (exists && isWindows) {
106+
try {
107+
// On Windows and if the file exists, we use a different strategy of saving the file
108+
// by first truncating the file and then writing with r+ mode. This helps to save hidden files on Windows
109+
// (see https://github.com/Microsoft/vscode/issues/931) and prevent removing alternate data streams
110+
// (see https://github.com/Microsoft/vscode/issues/6363)
111+
await truncate(filePath, 0);
112+
113+
// We heard from one user that fs.truncate() succeeds, but the save fails (https://github.com/Microsoft/vscode/issues/61310)
114+
// In that case, the file is now entirely empty and the contents are gone. This can happen if an external file watcher is
115+
// installed that reacts on the truncate and keeps the file busy right after. Our workaround is to retry to save after a
116+
// short timeout, assuming that the file is free to write then.
117+
await retry(() => writeFile(filePath, content, { flag: 'r+' }), 100 /* ms delay */, 3 /* retries */);
118+
} catch (error) {
119+
// we heard from users that fs.truncate() fails (https://github.com/Microsoft/vscode/issues/59561)
120+
// in that case we simply save the file without truncating first (same as macOS and Linux)
121+
await writeFile(filePath, content);
122+
}
123+
}
124+
125+
// macOS/Linux: just write directly
126+
else {
127+
await writeFile(filePath, content);
128+
}
129+
} catch (error) {
130+
throw this.toFileSystemProviderError(error);
131+
}
87132
}
88133

89134
open(resource: URI, opts: FileOpenOptions): Promise<number> {
@@ -137,6 +182,10 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro
137182
const fromFilePath = this.toFilePath(from);
138183
const toFilePath = this.toFilePath(to);
139184

185+
// Ensure target does not exist
186+
await this.validateTargetDeleted(from, to, opts && opts.overwrite);
187+
188+
// Move
140189
await move(fromFilePath, toFilePath);
141190
} catch (error) {
142191
throw this.toFileSystemProviderError(error);
@@ -148,12 +197,33 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro
148197
const fromFilePath = this.toFilePath(from);
149198
const toFilePath = this.toFilePath(to);
150199

151-
return copy(fromFilePath, toFilePath);
200+
// Ensure target does not exist
201+
await this.validateTargetDeleted(from, to, opts && opts.overwrite);
202+
203+
// Copy
204+
await copy(fromFilePath, toFilePath);
152205
} catch (error) {
153206
throw this.toFileSystemProviderError(error);
154207
}
155208
}
156209

210+
private async validateTargetDeleted(from: URI, to: URI, overwrite?: boolean): Promise<void> {
211+
const fromFilePath = this.toFilePath(from);
212+
const toFilePath = this.toFilePath(to);
213+
214+
const isPathCaseSensitive = !!(this.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
215+
const isCaseChange = isPathCaseSensitive ? false : isEqual(fromFilePath, toFilePath, true /* ignore case */);
216+
217+
// handle existing target (unless this is a case change)
218+
if (!isCaseChange && await fileExists(toFilePath)) {
219+
if (!overwrite) {
220+
throw createFileSystemProviderError(new Error('File at target already exists'), FileSystemProviderErrorCode.FileExists);
221+
}
222+
223+
await this.delete(to, { recursive: true });
224+
}
225+
}
226+
157227
//#endregion
158228

159229
//#region File Watching

0 commit comments

Comments
 (0)