diff --git a/src/vs/base/node/stream.ts b/src/vs/base/node/stream.ts index 1a71b176fdb96eb00f2c5dbb12d3bfaf36a52d9b..ee23b7037c8b39776c042b83445b5720aa4a7daf 100644 --- a/src/vs/base/node/stream.ts +++ b/src/vs/base/node/stream.ts @@ -94,6 +94,72 @@ export function readExactlyByFile(file: string, totalBytes: number, callback: (e }); } + loop(); + }); +} + +/** + * Reads a file until a matching string is found. + * + * @param file The file to read. + * @param matchingString The string to search for. + * @param chunkBytes The number of bytes to read each iteration. + * @param maximumBytesToRead The maximum number of bytes to read before giving up. + * @param callback The finished callback. + */ +export function readToMatchingString(file: string, matchingString: string, chunkBytes: number, maximumBytesToRead: number, callback: (error: Error, result: string) => void): void { + fs.open(file, 'r', null, (err, fd) => { + if (err) { + return callback(err, null); + } + + function end(err: Error, result: string): void { + fs.close(fd, (closeError: Error) => { + if (closeError) { + return callback(closeError, null); + } + + if (err && (err).code === 'EISDIR') { + return callback(err, null); // we want to bubble this error up (file is actually a folder) + } + + return callback(null, result); + }); + } + + let buffer = new Buffer(maximumBytesToRead); + let bytesRead = 0; + let zeroAttempts = 0; + function loop(): void { + fs.read(fd, buffer, bytesRead, chunkBytes, null, (err, moreBytesRead) => { + if (err) { + return end(err, null); + } + + // Retry up to N times in case 0 bytes where read + if (moreBytesRead === 0) { + if (++zeroAttempts === 10) { + return end(null, null); + } + + return loop(); + } + + bytesRead += moreBytesRead; + + const newLineIndex = buffer.indexOf(matchingString); + if (newLineIndex >= 0) { + return end(null, buffer.toString('utf8').substr(0, newLineIndex)); + } + + if (bytesRead >= maximumBytesToRead) { + return end(new Error(`Could not find ${matchingString} in first ${maximumBytesToRead} bytes of ${file}`), null); + } + + return loop(); + }); + } + loop(); }); } \ No newline at end of file diff --git a/src/vs/base/test/node/stream/stream.test.ts b/src/vs/base/test/node/stream/stream.test.ts index 7c674aea7c819ca54b533ad527ab37d81d6a3f26..b7c14bd37ebc679fd1a6faa58600fcb685c5dc74 100644 --- a/src/vs/base/test/node/stream/stream.test.ts +++ b/src/vs/base/test/node/stream/stream.test.ts @@ -56,4 +56,26 @@ suite('Stream', () => { done(); }); }); + + test('readToMatchingString - ANSI', function (done: () => void) { + var file = require.toUrl('./fixtures/file.css'); + + stream.readToMatchingString(file, '\n', 10, 100, (error: Error, result: string) => { + assert.equal(error, null); + assert.equal(result, '/*---------------------------------------------------------------------------------------------'); + + done(); + }); + }); + + test('readToMatchingString - empty', function (done: () => void) { + var file = require.toUrl('./fixtures/empty.txt'); + + stream.readToMatchingString(file, '\n', 10, 100, (error: Error, result: string) => { + assert.equal(error, null); + assert.equal(result, null); + + done(); + }); + }); }); \ No newline at end of file diff --git a/src/vs/test/utils/servicesTestUtils.ts b/src/vs/test/utils/servicesTestUtils.ts index 156dd524bacdea7b025840f25e46f5e0494ed602..3f03d797cedfd9b0d9e5267877543211e1646f28 100644 --- a/src/vs/test/utils/servicesTestUtils.ts +++ b/src/vs/test/utils/servicesTestUtils.ts @@ -643,23 +643,6 @@ export class TestBackupService implements IBackupService { export class TestBackupFileService implements IBackupFileService { public _serviceBrand: any; - - public getWorkspaceBackupPaths(): TPromise { - return TPromise.as([]); - } - - public getWorkspaceBackupPathsSync(): string[] { - return []; - } - - public pushWorkspaceBackupPathsSync(workspaces: URI[]): void { - return null; - } - - public getWorkspaceTextFilesWithBackupsSync(workspace: URI): string[] { - return []; - } - public hasBackup(resource: URI): TPromise { return TPromise.as(false); } @@ -690,6 +673,14 @@ export class TestBackupFileService implements IBackupFileService { return TPromise.as(void 0); } + public getWorkspaceFileBackups(scheme: string): TPromise { + return TPromise.as([]); + } + + public parseBackupContent(rawText: IRawTextContent): string { + return rawText.value.lines.join('\n'); + } + public discardResourceBackup(resource: URI): TPromise { return TPromise.as(void 0); } diff --git a/src/vs/workbench/common/editor/untitledEditorModel.ts b/src/vs/workbench/common/editor/untitledEditorModel.ts index c99419876565eb4d781cea6d9739d41e13bb2e04..88b5dcc8a2083385e4db6e560f4fa236ebe28c2f 100644 --- a/src/vs/workbench/common/editor/untitledEditorModel.ts +++ b/src/vs/workbench/common/editor/untitledEditorModel.ts @@ -160,7 +160,9 @@ export class UntitledEditorModel extends StringEditorModel implements IEncodingS // Check for backups first return this.backupFileService.loadBackupResource(this.resource).then(backupResource => { if (backupResource) { - return this.textFileService.resolveTextContent(backupResource, BACKUP_FILE_RESOLVE_OPTIONS).then(rawTextContent => rawTextContent.value.lines.join('\n')); + return this.textFileService.resolveTextContent(backupResource, BACKUP_FILE_RESOLVE_OPTIONS).then(rawTextContent => { + return this.backupFileService.parseBackupContent(rawTextContent); + }); } return null; diff --git a/src/vs/workbench/services/backup/common/backup.ts b/src/vs/workbench/services/backup/common/backup.ts index 2fcda4055cc82fa3d7639fa1ab927f67e86cee61..33f29712b316cb1e8db7cd82e1935f65444fc399 100644 --- a/src/vs/workbench/services/backup/common/backup.ts +++ b/src/vs/workbench/services/backup/common/backup.ts @@ -8,7 +8,7 @@ import Uri from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { TPromise } from 'vs/base/common/winjs.base'; -import { ITextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textfiles'; +import { ITextFileEditorModelManager, IRawTextContent } from 'vs/workbench/services/textfile/common/textfiles'; import { IResolveContentOptions, IUpdateContentOptions } from 'vs/platform/files/common/files'; import { ShutdownReason } from 'vs/platform/lifecycle/common/lifecycle'; @@ -57,6 +57,23 @@ export interface IBackupFileService { */ backupResource(resource: Uri, content: string, versionId?: number): TPromise; + /** + * Gets a list of file backups for the current workspace. + * + * @param scheme The scheme of the backup. + * @return The list of backups. + */ + getWorkspaceFileBackups(scheme: string): TPromise; + + /** + * Parses backup raw text content into the content, removing the metadata that is also stored + * in the file. + * + * @param rawText The IRawTextContent from a backup resource. + * @return The backup file's backed up content. + */ + parseBackupContent(rawText: IRawTextContent): string; + /** * Discards the backup associated with a resource if it exists.. * diff --git a/src/vs/workbench/services/backup/node/backupFileService.ts b/src/vs/workbench/services/backup/node/backupFileService.ts index d94ac8aa88b0d2fd4fa015261d19ae2535078986..5a53754d599df05b5d1211440f00303358782fb1 100644 --- a/src/vs/workbench/services/backup/node/backupFileService.ts +++ b/src/vs/workbench/services/backup/node/backupFileService.ts @@ -13,6 +13,8 @@ import { IBackupFileService, BACKUP_FILE_UPDATE_OPTIONS } from 'vs/workbench/ser import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IFileService } from 'vs/platform/files/common/files'; import { TPromise } from 'vs/base/common/winjs.base'; +import { readToMatchingString } from 'vs/base/node/stream'; +import { IRawTextContent } from 'vs/workbench/services/textfile/common/textfiles'; export interface IBackupFilesModel { resolve(backupRoot: string): TPromise; @@ -21,6 +23,7 @@ export interface IBackupFilesModel { has(resource: Uri, versionId?: number): boolean; remove(resource: Uri): void; clear(): void; + getFilesByScheme(scheme: string): Uri[]; } // TODO@daniel this should resolve the backups with their file names once we have the metadata in place @@ -64,6 +67,10 @@ export class BackupFilesModel implements IBackupFilesModel { return true; } + public getFilesByScheme(scheme: string): Uri[] { + return Object.keys(this.cache).filter(k => path.basename(path.dirname(k)) === scheme).map(k => Uri.parse(k)); + } + public remove(resource: Uri): void { delete this.cache[resource.toString()]; } @@ -147,6 +154,9 @@ export class BackupFileService implements IBackupFileService { return void 0; // return early if backup version id matches requested one } + // Add metadata to top of file + content = `${resource.toString()}\n${content}`; + return this.fileService.updateContent(backupResource, content, BACKUP_FILE_UPDATE_OPTIONS).then(() => model.add(backupResource, versionId)); }); } @@ -172,13 +182,33 @@ export class BackupFileService implements IBackupFileService { }); } + public getWorkspaceFileBackups(scheme: string): TPromise { + return this.ready.then(model => { + let readPromises: TPromise[] = []; + model.getFilesByScheme(scheme).forEach(textFile => { + readPromises.push(new TPromise((c, e) => { + readToMatchingString(textFile.fsPath, '\n', 2000, 10000, (error, result) => { + if (result === null) { + e(error); + } + c(Uri.parse(result)); + }); + })); + }); + return TPromise.join(readPromises); + }); + } + + public parseBackupContent(rawText: IRawTextContent): string { + return rawText.value.lines.slice(1).join('\n'); + } + protected getBackupResource(resource: Uri): Uri { if (!this.backupEnabled) { return null; } - // Only hash the file path if the file is not untitled - const backupName = resource.scheme === 'untitled' ? resource.fsPath : crypto.createHash('md5').update(resource.fsPath).digest('hex'); + const backupName = crypto.createHash('md5').update(resource.fsPath).digest('hex'); const backupPath = path.join(this.backupWorkspacePath, resource.scheme, backupName); return Uri.file(backupPath); diff --git a/src/vs/workbench/services/backup/test/backupFileService.test.ts b/src/vs/workbench/services/backup/test/backupFileService.test.ts index a2e18eb222ecd84f9d1f2033b4a4e8363eb219db..c85bb5f73ce493195e5e34949e42a6ebe827acf9 100644 --- a/src/vs/workbench/services/backup/test/backupFileService.test.ts +++ b/src/vs/workbench/services/backup/test/backupFileService.test.ts @@ -18,6 +18,8 @@ import { BackupFileService, BackupFilesModel } from 'vs/workbench/services/backu import { FileService } from 'vs/workbench/services/files/node/fileService'; import { EnvironmentService } from 'vs/platform/environment/node/environmentService'; import { parseArgs } from 'vs/platform/environment/node/argv'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { IRawTextContent } from 'vs/workbench/services/textfile/common/textfiles'; class TestEnvironmentService extends EnvironmentService { @@ -48,14 +50,14 @@ suite('BackupFileService', () => { const backupHome = path.join(parentDir, 'Backups'); const workspacesJsonPath = path.join(backupHome, 'workspaces.json'); - const workspaceResource = Uri.file(platform.isWindows ? 'C:\\workspace' : '/workspace'); + const workspaceResource = Uri.file(platform.isWindows ? 'c:\\workspace' : '/workspace'); const workspaceBackupPath = path.join(backupHome, crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex')); - const fooFile = Uri.file(platform.isWindows ? 'C:\\foo' : '/foo'); - const barFile = Uri.file(platform.isWindows ? 'C:\\bar' : '/bar'); + const fooFile = Uri.file(platform.isWindows ? 'c:\\foo' : '/foo'); + const barFile = Uri.file(platform.isWindows ? 'c:\\bar' : '/bar'); const untitledFile = Uri.from({ scheme: 'untitled', path: 'Untitled-1' }); const fooBackupPath = path.join(workspaceBackupPath, 'file', crypto.createHash('md5').update(fooFile.fsPath).digest('hex')); const barBackupPath = path.join(workspaceBackupPath, 'file', crypto.createHash('md5').update(barFile.fsPath).digest('hex')); - const untitledBackupPath = path.join(workspaceBackupPath, 'untitled', untitledFile.fsPath); + const untitledBackupPath = path.join(workspaceBackupPath, 'untitled', crypto.createHash('md5').update(untitledFile.fsPath).digest('hex')); let service: TestBackupFileService; @@ -89,7 +91,8 @@ suite('BackupFileService', () => { // Format should be: /// const backupResource = Uri.from({ scheme: 'untitled', path: 'Untitled-1' }); const workspaceHash = crypto.createHash('md5').update(workspaceResource.fsPath).digest('hex'); - const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'untitled', backupResource.fsPath)).fsPath; + const filePathHash = crypto.createHash('md5').update(backupResource.fsPath).digest('hex'); + const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'untitled', filePathHash)).fsPath; assert.equal(service.getBackupResource(backupResource).fsPath, expectedPath); }); @@ -108,7 +111,7 @@ suite('BackupFileService', () => { service.backupResource(fooFile, 'test').then(() => { assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); assert.equal(fs.existsSync(fooBackupPath), true); - assert.equal(fs.readFileSync(fooBackupPath), 'test'); + assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\ntest`); done(); }); }); @@ -117,7 +120,7 @@ suite('BackupFileService', () => { service.backupResource(untitledFile, 'test').then(() => { assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); assert.equal(fs.existsSync(untitledBackupPath), true); - assert.equal(fs.readFileSync(untitledBackupPath), 'test'); + assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\ntest`); done(); }); }); @@ -170,6 +173,62 @@ suite('BackupFileService', () => { }); }); + test('getWorkspaceFileBackups("file") - text file', done => { + service.backupResource(fooFile, `test`).then(() => { + service.getWorkspaceFileBackups('file').then(textFiles => { + assert.deepEqual(textFiles.map(f => f.fsPath), [fooFile.fsPath]); + service.backupResource(barFile, `test`).then(() => { + service.getWorkspaceFileBackups('file').then(textFiles => { + assert.deepEqual(textFiles.map(f => f.fsPath), [fooFile.fsPath, barFile.fsPath]); + done(); + }); + }); + }); + }); + }); + + test('getWorkspaceFileBackups("file") - untitled file', done => { + service.backupResource(untitledFile, `test`).then(() => { + service.getWorkspaceFileBackups('file').then(textFiles => { + assert.deepEqual(textFiles, []); + done(); + }); + }); + }); + + test('getWorkspaceFileBackups("untitled") - text file', done => { + service.backupResource(fooFile, `test`).then(() => { + service.backupResource(barFile, `test`).then(() => { + service.getWorkspaceFileBackups('untitled').then(textFiles => { + assert.deepEqual(textFiles, []); + done(); + }); + }); + }); + }); + + test('getWorkspaceFileBackups("untitled") - untitled file', done => { + service.backupResource(untitledFile, `test`).then(() => { + service.getWorkspaceFileBackups('untitled').then(textFiles => { + assert.deepEqual(textFiles.map(f => f.fsPath), ['Untitled-1']); + done(); + }); + }); + }); + + test('parseBackupContent', () => { + const rawTextContent: IRawTextContent = { + resource: null, + name: null, + mtime: null, + etag: null, + encoding: null, + value: TextModel.toRawText('metadata\ncontent', TextModel.DEFAULT_CREATION_OPTIONS), + valueLogicalHash: null + }; + assert.equal(service.parseBackupContent(rawTextContent), 'content'); + }); + test('BackupFilesModel - simple', () => { const model = new BackupFilesModel(); @@ -230,4 +289,22 @@ suite('BackupFileService', () => { }); }); }); + + test('BackupFilesModel - getFilesByScheme', () => { + const model = new BackupFilesModel(); + + assert.deepEqual(model.getFilesByScheme('file'), []); + assert.deepEqual(model.getFilesByScheme('untitled'), []); + + const file1 = Uri.file('/root/file/foo.html'); + const file2 = Uri.file('/root/file/bar.html'); + const untitled = Uri.file('/root/untitled/bar.html'); + + model.add(file1); + model.add(file2); + model.add(untitled); + + assert.deepEqual(model.getFilesByScheme('file').map(f => f.fsPath), [file1.fsPath, file2.fsPath]); + assert.deepEqual(model.getFilesByScheme('untitled').map(f => f.fsPath), [untitled.fsPath]); + }); }); \ No newline at end of file diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 9691e135178499f41fdf98e0e200608670c56a7a..c40ee5e3ab4cd0c8163f57ef82a481ce6b714ec4 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -290,11 +290,14 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil diag('load() - created text editor model', this.resource, new Date()); return this.backupFileService.loadBackupResource(this.resource).then(backupResource => { - let resolveBackupPromise: TPromise; + let resolveBackupPromise: TPromise; // Try get restore content, if there is an issue fallback silently to the original file's content if (backupResource) { - resolveBackupPromise = this.textFileService.resolveTextContent(backupResource, BACKUP_FILE_RESOLVE_OPTIONS).then(backup => backup.value, error => content.value); + resolveBackupPromise = this.textFileService.resolveTextContent(backupResource, BACKUP_FILE_RESOLVE_OPTIONS).then(backup => { + // The first line of a backup text file is the file name + return this.backupFileService.parseBackupContent(backup); + }, error => content.value); } else { resolveBackupPromise = TPromise.as(content.value); }