diff --git a/source-control-sample/README.md b/source-control-sample/README.md index 58ecdedc..e0b8baeb 100644 --- a/source-control-sample/README.md +++ b/source-control-sample/README.md @@ -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. \ No newline at end of file +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. diff --git a/source-control-sample/src/afs.ts b/source-control-sample/src/afs.ts new file mode 100644 index 00000000..dacbcff1 --- /dev/null +++ b/source-control-sample/src/afs.ts @@ -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(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 { + return new Promise((resolve, reject) => { + fs.readFile(path, (error, buffer) => handleResult(resolve, reject, error, buffer)); + }); +} + +export function writeFile(path: string, content: Buffer): Promise { + return new Promise((resolve, reject) => { + fs.writeFile(path, content, error => handleResult(resolve, reject, error, void 0)); + }); +} + +export function exists(path: string): Promise { + return new Promise((resolve, reject) => { + fs.exists(path, exists => handleResult(resolve, reject, null, exists)); + }); +} + +export function readdir(path: string): Promise { + return new Promise((resolve, reject) => { + fs.readdir(path, (error, files) => handleResult(resolve, reject, error, files)); + }); +} + +export function unlink(path: string): Promise { + return new Promise((resolve, reject) => { + fs.unlink(path, error => handleResult(resolve, reject, error, void 0)); + }); +} \ No newline at end of file diff --git a/source-control-sample/src/extension.ts b/source-control-sample/src/extension.ts index e6f2572e..5201e415 100644 --- a/source-control-sample/src/extension.ts +++ b/source-control-sample/src/extension.ts @@ -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(); // 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 { // 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 { 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 { 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(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 = 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 { 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 { +async function clearWorkspaceFolder(workspaceFolderUri: vscode.Uri): Promise { - 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 }); -} \ No newline at end of file +} + +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); diff --git a/source-control-sample/src/fiddleConfiguration.ts b/source-control-sample/src/fiddleConfiguration.ts index 36a93e2a..2745402d 100644 --- a/source-control-sample/src/fiddleConfiguration.ts +++ b/source-control-sample/src/fiddleConfiguration.ts @@ -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 }; } \ No newline at end of file diff --git a/source-control-sample/src/fiddleSourceControl.ts b/source-control-sample/src/fiddleSourceControl.ts index a6039538..35832882 100644 --- a/source-control-sample/src/fiddleSourceControl.ts +++ b/source-control-sample/src/fiddleSourceControl.ts @@ -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 { 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 { @@ -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 { @@ -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; diff --git a/source-control-sample/src/util.ts b/source-control-sample/src/util.ts index 9b17400b..a282e001 100644 --- a/source-control-sample/src/util.ts +++ b/source-control-sample/src/util.ts @@ -7,3 +7,5 @@ export function firstIndex(array: T[], fn: (t: T) => boolean): number { return -1; } + +export const UTF8 = 'utf8'; \ No newline at end of file