- awaiting async fs operation to avoid race conditions

- more flexible handling of workspace folders
This commit is contained in:
Jan Dolejsi
2019-08-03 23:42:21 +02:00
parent e5418aced1
commit 4bf4061a8f
6 changed files with 211 additions and 71 deletions

View File

@ -142,4 +142,4 @@ Source control extension should support multiple workspace folders bound to diff
## Testing the sample
This sample can be tested on any JSFiddle with (e.g. `u8B29/1`) or without (e.g. `u8B29`) the version number specified. However, if you want to test committing code back to the repository, try typing in `demo` instead of a real fiddle name, which lets you mock the repository without reading/writing from/to actual JSFiddle.
This sample can be tested on any JSFiddle with (e.g. `u8B29/1`) or without (e.g. `u8B29`) the version number specified. However, if you want to test committing code back to the repository, try typing in `demo` instead of a real fiddle name, which lets you mock the repository without reading/writing from/to actual JSFiddle.

View File

@ -0,0 +1,64 @@
// This file promisifies necessary file system functions.
// This should be removed when VS Code updates to Node.js ^11.14 and replaced by the native fs promises.
import * as vscode from 'vscode';
import * as fs from 'fs';
function handleResult<T>(resolve: (result: T) => void, reject: (error: Error) => void, error: Error | null | undefined, result: T): void {
if (error) {
reject(massageError(error));
} else {
resolve(result);
}
}
function massageError(error: Error & { code?: string }): Error {
if (error.code === 'ENOENT') {
return vscode.FileSystemError.FileNotFound();
}
if (error.code === 'EISDIR') {
return vscode.FileSystemError.FileIsADirectory();
}
if (error.code === 'EEXIST') {
return vscode.FileSystemError.FileExists();
}
if (error.code === 'EPERM' || error.code === 'EACCESS') {
return vscode.FileSystemError.NoPermissions();
}
return error;
}
export function readFile(path: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
fs.readFile(path, (error, buffer) => handleResult(resolve, reject, error, buffer));
});
}
export function writeFile(path: string, content: Buffer): Promise<void> {
return new Promise<void>((resolve, reject) => {
fs.writeFile(path, content, error => handleResult(resolve, reject, error, void 0));
});
}
export function exists(path: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
fs.exists(path, exists => handleResult(resolve, reject, null, exists));
});
}
export function readdir(path: string): Promise<string[]> {
return new Promise<string[]>((resolve, reject) => {
fs.readdir(path, (error, files) => handleResult(resolve, reject, error, files));
});
}
export function unlink(path: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
fs.unlink(path, error => handleResult(resolve, reject, error, void 0));
});
}

View File

@ -5,9 +5,9 @@ import { JSFIDDLE_SCHEME } from './fiddleRepository';
import { FiddleSourceControl, CONFIGURATION_FILE } from './fiddleSourceControl';
import { JSFiddleDocumentContentProvider } from './fiddleDocumentContentProvider';
import * as path from 'path';
import { unlinkSync, readdirSync, existsSync, exists, readFile } from 'fs';
import * as afs from './afs';
import { FiddleConfiguration, parseFiddleId } from './fiddleConfiguration';
import { firstIndex } from './util';
import { firs tIndex, UTF8 } from './util';
const SOURCE_CONTROL_OPEN_COMMAND = 'extension.source-control.open';
var jsFiddleDocumentContentProvider: JSFiddleDocumentContentProvider;
@ -15,12 +15,18 @@ var fiddleSourceControlRegister = new Map<vscode.Uri, FiddleSourceControl>();
// this method is called when your extension is activated
// your extension is activated the very first time the command is executed
export function activate(context: vscode.ExtensionContext) {
export async function activate(context: vscode.ExtensionContext) {
console.log('Congratulations, your extension "source-control-sample" is now active!');
jsFiddleDocumentContentProvider = new JSFiddleDocumentContentProvider();
initializeFromConfigurationFile(context);
try {
await initializeFromConfigurationFile(context);
}
catch (err) {
console.log('Failed to initialize a Fiddle workspace.');
vscode.window.showErrorMessage(err);
}
let openCommand = vscode.commands.registerCommand(SOURCE_CONTROL_OPEN_COMMAND,
(fiddleId?: string, workspaceUri?: vscode.Uri) => {
@ -58,15 +64,19 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(e => {
// dispose source control for removed workspace folders
e.removed.forEach(wf => {
unregisterFiddleSourceControl(wf.uri);
});
// initialize new source control for manually added workspace folders
e.added.forEach(wf => {
initializeFolderFromConfigurationFile(wf, context);
});
try {
// initialize new source control for manually added workspace folders
e.added.forEach(wf => {
initializeFolderFromConfigurationFile(wf, context);
});
} catch (ex) {
vscode.window.showErrorMessage(ex.message);
} finally {
// dispose source control for removed workspace folders
e.removed.forEach(wf => {
unregisterFiddleSourceControl(wf.uri);
});
}
}));
}
@ -121,8 +131,7 @@ async function openFiddle(context: vscode.ExtensionContext, fiddleId?: string, w
vscode.workspace.getWorkspaceFolder(workspaceUri) :
await selectWorkspaceFolder(context, fiddleId);
workspaceFolder = await clearWorkspaceFolder(workspaceFolder);
if (!workspaceFolder) { return; } // canceled by user
if (! await clearWorkspaceFolder(workspaceFolder.uri)) { return; }
// show the file explorer with the three new files
vscode.commands.executeCommand("workbench.view.explorer");
@ -131,7 +140,10 @@ async function openFiddle(context: vscode.ExtensionContext, fiddleId?: string, w
let fiddleSourceControl = await FiddleSourceControl.fromFiddleId(fiddleId, context, workspaceFolder, true);
registerFiddleSourceControl(fiddleSourceControl, context);
showFiddleInEditor(fiddleSourceControl);
}
async function showFiddleInEditor(fiddleSourceControl: FiddleSourceControl): Promise<void> {
// open the 3 fiddle parts in 3 view columns
await openDocumentInColumn(fiddleSourceControl.getRepository().createLocalResourcePath('html'), vscode.ViewColumn.One);
await openDocumentInColumn(fiddleSourceControl.getRepository().createLocalResourcePath('js'), vscode.ViewColumn.Two);
@ -169,93 +181,117 @@ function unregisterFiddleSourceControl(folderUri: vscode.Uri): void {
* When the extension starts up, it must visit all workspace folders to see if any of them are fiddles.
* @param context extension context
*/
function initializeFromConfigurationFile(context: vscode.ExtensionContext): void {
async function initializeFromConfigurationFile(context: vscode.ExtensionContext): Promise<void> {
if (!vscode.workspace.workspaceFolders) { return; }
vscode.workspace.workspaceFolders.forEach(folder => initializeFolderFromConfigurationFile(folder, context));
let folderPromises = vscode.workspace.workspaceFolders.map(async (folder) => await initializeFolderFromConfigurationFile(folder, context));
await Promise.all(folderPromises);
}
function initializeFolderFromConfigurationFile(folder: vscode.WorkspaceFolder, context: vscode.ExtensionContext): void {
async function initializeFolderFromConfigurationFile(folder: vscode.WorkspaceFolder, context: vscode.ExtensionContext): Promise<void> {
const configurationPath = path.join(folder.uri.fsPath, CONFIGURATION_FILE);
exists(configurationPath, configFileExists => {
if (configFileExists) {
readFile(configurationPath, { flag: 'r' }, async (err, data) => {
if (err) { vscode.window.showErrorMessage(err.message); }
try {
let fiddleSourceControl = await FiddleSourceControl.fromConfiguration(<FiddleConfiguration>JSON.parse(data.toString("utf-8")), folder, context, false);
registerFiddleSourceControl(fiddleSourceControl, context);
} catch (ex) {
vscode.window.showErrorMessage(ex);
}
});
}
});
let configFileExists = await afs.exists(configurationPath);
if (configFileExists) {
let data = await afs.readFile(configurationPath);
let fiddleConfiguration = <FiddleConfiguration>JSON.parse(data.toString(UTF8));
let fiddleSourceControl = await FiddleSourceControl.fromConfiguration(fiddleConfiguration, folder, context, !fiddleConfiguration.downloaded);
registerFiddleSourceControl(fiddleSourceControl, context);
if (!fiddleConfiguration.downloaded) {
// the fiddle was not downloaded before the extension restart, so let's show it now
showFiddleInEditor(fiddleSourceControl);
}
}
}
async function selectWorkspaceFolder(context: vscode.ExtensionContext, fiddleId: string): Promise<vscode.WorkspaceFolder | undefined> {
var selectedFolder: vscode.WorkspaceFolder | undefined;
var workspaceFolderUri: vscode.Uri | undefined;
var workspaceFolderIndex: number | undefined;
var folderOpeningMode: FolderOpeningMode;
const fiddleConfiguration = parseFiddleId(fiddleId);
var folderPicks: WorkspaceFolderPick[] = [newFolderPick];
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) {
selectedFolder = await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Pick workspace folder to create files in.' });
if (!selectedFolder) { return undefined; }
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
folderPicks.push(newWorkspaceFolderPick);
for (const wf of vscode.workspace.workspaceFolders) {
let content = await afs.readdir(wf.uri.fsPath);
folderPicks.push(new ExistingWorkspaceFolderPick(wf, content));
}
}
let selectedFolderPick: WorkspaceFolderPick =
folderPicks.length === 1 ?
folderPicks[0] :
await vscode.window.showQuickPick(folderPicks, {
canPickMany: false, ignoreFocusOut: true, placeHolder: 'Pick workspace folder to create files in.'
});
if (!selectedFolderPick) { return null; }
if (selectedFolderPick instanceof ExistingWorkspaceFolderPick) {
selectedFolder = selectedFolderPick.workspaceFolder;
workspaceFolderIndex = selectedFolder.index;
workspaceFolderUri = selectedFolder.uri;
}
else if (!vscode.workspace.workspaceFolders) {
folderOpeningMode = selectedFolderPick.folderOpeningMode;
if (!workspaceFolderUri && !selectedFolder) {
let folderUris = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, openLabel: 'Select folder' });
if (!folderUris) {
return undefined;
return null;
}
workspaceFolderUri = folderUris[0];
// was such workspace folder already open?
workspaceFolderIndex = vscode.workspace.workspaceFolders && firstIndex(vscode.workspace.workspaceFolders, (folder1: any) => folder1.uri.toString() === workspaceFolderUri!.toString());
// save folder configuration
FiddleSourceControl.saveConfiguration(workspaceFolderUri, fiddleConfiguration);
selectedFolder = undefined; // the extension will get reloaded in the context of the newly open workspace
}
else {
selectedFolder = vscode.workspace.workspaceFolders[0];
}
let workSpacesToReplace = typeof workspaceFolderIndex === 'number' && workspaceFolderIndex > -1 ? 1 : 0;
if (workspaceFolderIndex === undefined || workspaceFolderIndex < 0) { workspaceFolderIndex = 0; }
if (! await clearWorkspaceFolder(workspaceFolderUri)) { return null; }
// replace or insert the workspace
if (workspaceFolderUri) {
vscode.workspace.updateWorkspaceFolders(workspaceFolderIndex, workSpacesToReplace, { uri: workspaceFolderUri });
const fiddleConfiguration = parseFiddleId(fiddleId);
// save folder configuration
FiddleSourceControl.saveConfiguration(workspaceFolderUri, fiddleConfiguration);
if (folderOpeningMode === FolderOpeningMode.AddToWorkspace || folderOpeningMode === undefined) {
let workSpacesToReplace = typeof workspaceFolderIndex === 'number' && workspaceFolderIndex > -1 ? 1 : 0;
if (workspaceFolderIndex === undefined || workspaceFolderIndex < 0) { workspaceFolderIndex = 0; }
// replace or insert the workspace
if (workspaceFolderUri) {
vscode.workspace.updateWorkspaceFolders(workspaceFolderIndex, workSpacesToReplace, { uri: workspaceFolderUri });
}
}
else if (folderOpeningMode === FolderOpeningMode.OpenFolder) {
vscode.commands.executeCommand("vscode.openFolder", workspaceFolderUri);
}
return selectedFolder;
}
async function clearWorkspaceFolder(workspaceFolder?: vscode.WorkspaceFolder): Promise<vscode.WorkspaceFolder | undefined> {
async function clearWorkspaceFolder(workspaceFolderUri: vscode.Uri): Promise<boolean> {
if (!workspaceFolder) { return undefined; }
if (!workspaceFolderUri) { return undefined; }
// check if the workspace is empty, or clear it
let existingWorkspaceFiles = readdirSync(workspaceFolder.uri.fsPath);
let existingWorkspaceFiles: string[] = await afs.readdir(workspaceFolderUri.fsPath);
if (existingWorkspaceFiles.length > 0) {
let answer = await vscode.window.showQuickPick(["Yes", "No"],
{ placeHolder: `Remove ${existingWorkspaceFiles.length} file(s) from the workspace before cloning the remote repository?` });
if (answer === undefined) { return undefined; }
{ placeHolder: `Remove ${existingWorkspaceFiles.length} file(s) from the folder ${workspaceFolderUri.fsPath} before cloning the remote repository?` });
if (!answer) { return false; }
if (answer === "Yes") {
existingWorkspaceFiles
.map(filename =>
unlinkSync(path.join(workspaceFolder.uri.fsPath, filename)));
.forEach(async filename =>
await afs.unlink(path.join(workspaceFolderUri.fsPath, filename)));
}
}
return workspaceFolder;
return true;
}
// this method is called when your extension is deactivated
@ -277,4 +313,39 @@ async function openDocumentInColumn(fileName: string, column: vscode.ViewColumn)
let doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc, { viewColumn: column });
}
}
abstract class WorkspaceFolderPick implements vscode.QuickPickItem {
label: string;
constructor(public folderOpeningMode: FolderOpeningMode) { }
}
class ExistingWorkspaceFolderPick extends WorkspaceFolderPick {
constructor(public readonly workspaceFolder: vscode.WorkspaceFolder, private content: string[]) {
super(FolderOpeningMode.AddToWorkspace);
}
get label(): string {
return this.workspaceFolder.name;
}
get description(): string {
return this.workspaceFolder.uri.fsPath;
}
get detail(): string {
return this.content.length ? `${this.content.length} files/directories may need to be removed..` : null;
}
}
class NewWorkspaceFolderPick extends WorkspaceFolderPick {
constructor(public label: string, folderOpeningMode: FolderOpeningMode) {
super(folderOpeningMode);
}
}
enum FolderOpeningMode { AddToWorkspace, OpenFolder }
const newWorkspaceFolderPick = new NewWorkspaceFolderPick("Select/create a local folder to add to this workspace...", FolderOpeningMode.AddToWorkspace);
const newFolderPick = new NewWorkspaceFolderPick("Select/create a local folder...", FolderOpeningMode.OpenFolder);

View File

@ -1,6 +1,7 @@
export interface FiddleConfiguration {
readonly slug: string;
readonly version?: number;
readonly downloaded: boolean;
}
export function parseFiddleId(id: string): FiddleConfiguration {
@ -8,5 +9,5 @@ export function parseFiddleId(id: string): FiddleConfiguration {
let fiddleSlug = idFragments[0];
let fiddleVersion = idFragments.length > 1 ? parseInt(id.split('/')[1]) : undefined;
return { slug: fiddleSlug, version: fiddleVersion };
return { slug: fiddleSlug, version: fiddleVersion, downloaded: false };
}

View File

@ -1,8 +1,9 @@
import * as vscode from 'vscode';
import { FiddleRepository, toExtension, downloadFiddle, areIdentical, uploadFiddle, Fiddle, toFiddleId } from './fiddleRepository';
import * as path from 'path';
import { writeFileSync, existsSync, writeFile } from 'fs';
import * as afs from './afs';
import { FiddleConfiguration, parseFiddleId } from './fiddleConfiguration';
import { UTF8 } from './util';
export const CONFIGURATION_FILE = '.jsfiddle';
@ -15,7 +16,7 @@ export class FiddleSourceControl implements vscode.Disposable {
private timeout?: NodeJS.Timer;
private fiddle!: Fiddle;
constructor(context: vscode.ExtensionContext, private readonly workspaceFolder: vscode.WorkspaceFolder, fiddle: Fiddle, overwrite: boolean) {
constructor(context: vscode.ExtensionContext, private readonly workspaceFolder: vscode.WorkspaceFolder, fiddle: Fiddle, download: boolean) {
this.jsFiddleScm = vscode.scm.createSourceControl('jsfiddle', 'JSFiddle #' + fiddle.slug, workspaceFolder.uri);
this.changedResources = this.jsFiddleScm.createResourceGroup('workingTree', 'Changes');
this.fiddleRepository = new FiddleRepository(workspaceFolder, fiddle.slug);
@ -31,7 +32,7 @@ export class FiddleSourceControl implements vscode.Disposable {
context.subscriptions.push(fileSystemWatcher);
// clone fiddle to the local workspace
this.setFiddle(fiddle, overwrite);
this.setFiddle(fiddle, download);
if (this.fiddle.version === undefined || Number.isNaN(this.fiddle.version)) {
this.establishVersion();
@ -106,9 +107,9 @@ export class FiddleSourceControl implements vscode.Disposable {
}
/** Resets the given local file content to the checked-out version. */
private resetFile(extension: string): void {
private async resetFile(extension: string): Promise<void> {
let filePath = this.fiddleRepository.createLocalResourcePath(extension);
writeFileSync(filePath, this.fiddle.data[extension]);
await afs.writeFile(filePath, this.fiddle.data[extension]);
}
async tryCheckout(newVersion: number | undefined): Promise<void> {
@ -172,7 +173,8 @@ export class FiddleSourceControl implements vscode.Disposable {
private saveCurrentConfiguration(): void {
let fiddleConfiguration: FiddleConfiguration = {
slug: this.fiddle.slug,
version: this.fiddle.version
version: this.fiddle.version,
downloaded: true
};
FiddleSourceControl.saveConfiguration(this.workspaceFolder.uri, fiddleConfiguration);
@ -180,9 +182,7 @@ export class FiddleSourceControl implements vscode.Disposable {
static saveConfiguration(workspaceFolderUri: vscode.Uri, fiddleConfiguration: FiddleConfiguration): void {
let fiddleConfigurationString = JSON.stringify(fiddleConfiguration);
writeFile(path.join(workspaceFolderUri.fsPath, CONFIGURATION_FILE), fiddleConfigurationString, err => {
vscode.window.showErrorMessage(err.message);
});
afs.writeFile(path.join(workspaceFolderUri.fsPath, CONFIGURATION_FILE), Buffer.from(fiddleConfigurationString, UTF8));
}
get onRepositoryChange(): vscode.Event<Fiddle> {
@ -214,7 +214,9 @@ export class FiddleSourceControl implements vscode.Disposable {
let isDirty: boolean;
let wasDeleted: boolean;
if (existsSync(uri.fsPath)) {
let pathExists = await afs.exists(uri.fsPath);
if (pathExists) {
let document = await vscode.workspace.openTextDocument(uri);
isDirty = this.isDirty(document);
wasDeleted = false;

View File

@ -7,3 +7,5 @@ export function firstIndex<T>(array: T[], fn: (t: T) => boolean): number {
return -1;
}
export const UTF8 = 'utf8';