diff --git a/source-control-sample/README.md b/source-control-sample/README.md index ad77df47..a71c03d9 100644 --- a/source-control-sample/README.md +++ b/source-control-sample/README.md @@ -23,9 +23,9 @@ Activate the extension by invoking the `Open JSFiddle` command, specify the JSFi 1. registers the `quickDiffProvider`, which implements the mapping between the documents in the remote repository and documents in the local folder. ```javascript -this.jsFiddleScm = vscode.scm.createSourceControl('jsfiddle', 'JSFiddle #' + fiddle.hash, workspaceFolder.uri); +this.jsFiddleScm = vscode.scm.createSourceControl('jsfiddle', 'JSFiddle #' + fiddle.slug, workspaceFolder.uri); this.changedResources = this.jsFiddleScm.createResourceGroup('workingTree', 'Changes'); -this.fiddleRepository = new FiddleRepository(workspaceFolder, fiddle.hash); +this.fiddleRepository = new FiddleRepository(workspaceFolder, fiddle.slug); this.jsFiddleScm.quickDiffProvider = this.fiddleRepository; ``` @@ -37,28 +37,28 @@ The three commands (command, roll-back and refresh) in the title of the source c ```JSON "contributes": { - "commands": [ - ... - ], - "menus": { - "scm/title": [ - { - "command": "extension.source-control.commit", - "group": "navigation", - "when": "scmProvider == jsfiddle" - }, - { - "command": "extension.source-control.discard", - "group": "navigation", - "when": "scmProvider == jsfiddle" - }, - { - "command": "extension.source-control.refresh", - "group": "navigation", - "when": "scmProvider == jsfiddle" - } - ] - } + "commands": [ + ... + ], + "menus": { + "scm/title": [ + { + "command": "extension.source-control.commit", + "group": "navigation", + "when": "scmProvider == jsfiddle" + }, + { + "command": "extension.source-control.discard", + "group": "navigation", + "when": "scmProvider == jsfiddle" + }, + { + "command": "extension.source-control.refresh", + "group": "navigation", + "when": "scmProvider == jsfiddle" + } + ] + } }, ``` @@ -66,15 +66,15 @@ It is also worth noting that the sample extension needs to overcome reloading th ## Status bar controls -The custom source control can add its own controls to the status bar. This typically needs to be refreshed everytime a new version/branch is checked-out. +The custom source control can add its own controls to the status bar. This typically needs to be refreshed every time a new version/branch is checked-out. ```javascript this.jsFiddleScm.statusBarCommands = [ - { - "command": "extension.source-control.checkout", - "title": `↕ ${this.fiddle.hash} #${this.fiddle.version} / ${this.latestFiddleVersion}`, - "tooltip": "Checkout another version of this fiddle.", - } + { + "command": "extension.source-control.checkout", + "title": `↕ ${this.fiddle.slug} #${this.fiddle.version} / ${this.latestFiddleVersion}`, + "tooltip": "Checkout another version of this fiddle.", + } ]; ``` @@ -86,21 +86,21 @@ The command `extension.source-control.checkout` displays quick pick of the JSFid The extension listens to changes to files in the workspace folder and compares the new document text to the version originally checked out from the repository. When it differs, it creates `vscode.SourceControlResourceState` for every changed document assigns such list to `this.changedResources.resourceStates`, where the `this.changedResources` was created earlier. -```JS +```javascript { - resourceUri: doc.uri, - command: { - title: "Show changes", - command: "vscode.diff", - arguments: [repositoryUri, doc.uri, `Checked-out version ↔ Local changes`], - tooltip: "Diff your changes" - } + resourceUri: doc.uri, + command: { + title: "Show changes", + command: "vscode.diff", + arguments: [repositoryUri, doc.uri, `Checked-out version ↔ Local changes`], + tooltip: "Diff your changes" + } } ``` where `repositoryUri` is determined by -```JS +```javascript this.fiddleRepository.provideOriginalResource(doc.uri, null) ``` @@ -109,3 +109,31 @@ this.fiddleRepository.provideOriginalResource(doc.uri, null) Both the regular diff (invoking the built-in `vscode.diff` when user clicks on the changed resource in the source control view) and the Quick Diff (available in the left margin of the text editor) are rendered automatically by VS Code as long as the extension provides it with the content of the original document checked out from the repository. This is done by implementing a `TextDocumentContentProvider`. ![alt text](resources/images/quick_diff.gif "Quick diff") + +## Source Control extension activation + +A source control extensions gets activated either when a remote repository gets _cloned_ +upon user request, or when a previously _cloned_ workspace folder is open. + +```JSON + "activationEvents": [ + "onCommand:extension.source-control.open", + "workspaceContains:.jsfiddle" + ], +``` + +This extension implements the _cloning_ using the `extension.source-control.open` command. + +This sample extension stores the source control configuration in the `.jsfiddle` JSON file in the workspace folder root. + +Upon extension activation such file is discovered in the workspace folder and source control initialized. + +## Multiple workspace folder support + +Source control extension should support multiple workspace folders bound to different remote repositories. + +![alt text](resources/images/multi-workspace-folder.gif "Multiple workspace folder support") + +## 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 diff --git a/source-control-sample/package.json b/source-control-sample/package.json index d81596e7..727adcde 100644 --- a/source-control-sample/package.json +++ b/source-control-sample/package.json @@ -8,10 +8,11 @@ "vscode": "^1.30.0" }, "categories": [ - "Other" + "SCM Providers" ], "activationEvents": [ - "onCommand:extension.source-control.open" + "onCommand:extension.source-control.open", + "workspaceContains:.jsfiddle" ], "main": "./out/extension.js", "contributes": { diff --git a/source-control-sample/resources/images/multi-workspace-folder.gif b/source-control-sample/resources/images/multi-workspace-folder.gif new file mode 100644 index 00000000..a3ed2e3e Binary files /dev/null and b/source-control-sample/resources/images/multi-workspace-folder.gif differ diff --git a/source-control-sample/src/extension.ts b/source-control-sample/src/extension.ts index 6480880b..00fe7a4f 100644 --- a/source-control-sample/src/extension.ts +++ b/source-control-sample/src/extension.ts @@ -2,84 +2,196 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; import { JSFIDDLE_SCHEME } from './fiddleRepository'; -import { FiddleSourceControl } from './fiddleSourceControl'; +import { FiddleSourceControl, CONFIGURATION_FILE } from './fiddleSourceControl'; import { JSFiddleDocumentContentProvider } from './fiddleDocumentContentProvider'; import * as path from 'path'; -import { unlinkSync, readdirSync } from 'fs'; +import { unlinkSync, readdirSync, existsSync, exists, readFile } from 'fs'; +import { FiddleConfiguration, parseFiddleId } from './fiddleConfiguration'; +import { firstIndex } from './util'; const SOURCE_CONTROL_OPEN_COMMAND = 'extension.source-control.open'; +var jsFiddleDocumentContentProvider: JSFiddleDocumentContentProvider; +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) { console.log('Congratulations, your extension "source-control-sample" is now active!'); - let fiddleId = context.globalState.get(FIDDLE_ID_TO_OPEN); - if (fiddleId) { - // new workspace folder was open and the extension needs to continue opening the fiddle files into it - vscode.commands.executeCommand(SOURCE_CONTROL_OPEN_COMMAND, fiddleId); - context.globalState.update(FIDDLE_ID_TO_OPEN, undefined); - } + jsFiddleDocumentContentProvider = new JSFiddleDocumentContentProvider(); - const jsFiddleDocumentContentProvider = new JSFiddleDocumentContentProvider(); - let fiddleSourceControl: FiddleSourceControl = null; + initializeFromConfigurationFile(context); - let disposable = vscode.commands.registerCommand(SOURCE_CONTROL_OPEN_COMMAND, async (id?: string) => { + let openCommand = vscode.commands.registerCommand(SOURCE_CONTROL_OPEN_COMMAND, + (fiddleId?: string, workspaceUri?: vscode.Uri) => { + tryOpenFiddle(context, fiddleId, workspaceUri); + }); + context.subscriptions.push(openCommand); - if (fiddleSourceControl) vscode.window.showErrorMessage("Another Fiddle was already open in this workspace. Open a new workspace first."); + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(JSFIDDLE_SCHEME, jsFiddleDocumentContentProvider)); - if (!id) { - id = await vscode.window.showInputBox({ prompt: 'Paste JSFiddle ID and optionally version', placeHolder: 'hash or hash/version, e.g. u8B29/1', value: 'u8B29/1' }); - } - - try { - - let workspaceFolder = await clearWorkspaceFolder(await getWorkspaceFolder(context, id)); - if (!workspaceFolder) return; // canceled by user - - // show the file explorer with the three new files - vscode.commands.executeCommand("workbench.view.explorer"); - - // register source control - let fiddleSourceControl = await FiddleSourceControl.fromFiddle(id, context, workspaceFolder); - - // update the fiddle document content provider with the latest content - jsFiddleDocumentContentProvider.updated(fiddleSourceControl.fiddle); - - // every time the repository is updated with new fiddle version, notify the content provider - fiddleSourceControl.onRepositoryChange(fiddle => jsFiddleDocumentContentProvider.updated(fiddle)); - - context.subscriptions.push(fiddleSourceControl); - } - catch (ex) { - vscode.window.showErrorMessage(ex); - console.log(ex); - } - }); - - vscode.workspace.registerTextDocumentContentProvider(JSFIDDLE_SCHEME, jsFiddleDocumentContentProvider); - - context.subscriptions.push(disposable); + context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.refresh", async () => { + let sourceControl = await pickSourceControl(); + if (sourceControl) sourceControl.refresh(); + })); + context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.discard", async () => { + let sourceControl = await pickSourceControl(); + if (sourceControl) sourceControl.resetFilesToCheckedOutVersion(); + })); + context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.commit", async () => { + let sourceControl = await pickSourceControl(); + if (sourceControl) sourceControl.commitAll(); + })); + context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.checkout", + async (sourceControl: FiddleSourceControl, newVersion?: number) => { + sourceControl = sourceControl || await pickSourceControl(); + if (sourceControl) sourceControl.tryCheckout(newVersion); + })); } -const FIDDLE_ID_TO_OPEN = "fiddleIdToOpen"; +async function pickSourceControl(): Promise { + // todo: when/if the SourceControl exposes a 'selected' property, use that instead + + if (fiddleSourceControlRegister.size == 0) return undefined; + else if (fiddleSourceControlRegister.size == 1) return [...fiddleSourceControlRegister.values()][0]; + else { + + let picks = [...fiddleSourceControlRegister.values()].map(fsc => new RepositoryPick(fsc)); + + if (vscode.window.activeTextEditor) { + let activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); + let activeSourceControl = activeWorkspaceFolder && fiddleSourceControlRegister.get(activeWorkspaceFolder.uri); + let activeIndex = firstIndex(picks, pick => pick.fiddleSourceControl === activeSourceControl); + + // if there is an active editor, move its folder to be the first in the pick list + if (activeIndex > -1) { + picks.unshift(...picks.splice(activeIndex, 1)); + } + } + + const pick = await vscode.window.showQuickPick(picks, { placeHolder: 'Select repository' }); + return pick && pick.fiddleSourceControl; + } +} + +async function tryOpenFiddle(context: vscode.ExtensionContext, fiddleId?: string, workspaceUri?: vscode.Uri): Promise { + try { + await openFiddle(context, fiddleId, workspaceUri); + } + catch (ex) { + vscode.window.showErrorMessage(ex); + console.log(ex); + } +} + +async function openFiddle(context: vscode.ExtensionContext, fiddleId?: string, workspaceUri?: vscode.Uri) { + if (fiddleSourceControlRegister.has(workspaceUri)) vscode.window.showErrorMessage("Another Fiddle was already open in this workspace. Open a new workspace first."); + + if (!fiddleId) { + fiddleId = await vscode.window.showInputBox({ prompt: 'Paste JSFiddle ID and optionally version', placeHolder: 'slug or slug/version, e.g. u8B29/1', value: 'demo' }); + } + + let workspaceFolder: vscode.WorkspaceFolder = + workspaceUri ? + vscode.workspace.getWorkspaceFolder(workspaceUri) : + await selectWorkspaceFolder(context, fiddleId); + + workspaceFolder = await clearWorkspaceFolder(workspaceFolder); + if (!workspaceFolder) return; // canceled by user + + // show the file explorer with the three new files + vscode.commands.executeCommand("workbench.view.explorer"); + + // register source control + let fiddleSourceControl = await FiddleSourceControl.fromFiddleId(fiddleId, context, workspaceFolder, true); + + registerFiddleSourceControl(fiddleSourceControl, context); + + // 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); + await openDocumentInColumn(fiddleSourceControl.getRepository().createLocalResourcePath('css'), vscode.ViewColumn.Three); +} + +function registerFiddleSourceControl(fiddleSourceControl: FiddleSourceControl, context: vscode.ExtensionContext) { + // update the fiddle document content provider with the latest content + jsFiddleDocumentContentProvider.updated(fiddleSourceControl.getFiddle()); + + // every time the repository is updated with new fiddle version, notify the content provider + fiddleSourceControl.onRepositoryChange(fiddle => jsFiddleDocumentContentProvider.updated(fiddle)); + + if (fiddleSourceControlRegister.has(fiddleSourceControl.getWorkspaceFolder().uri)) { + // the folder was already under source control + let previousSourceControl = fiddleSourceControlRegister.get(fiddleSourceControl.getWorkspaceFolder().uri); + previousSourceControl.dispose(); + } + + fiddleSourceControlRegister.set(fiddleSourceControl.getWorkspaceFolder().uri, fiddleSourceControl); + + context.subscriptions.push(fiddleSourceControl); +} + +function initializeFromConfigurationFile(context: vscode.ExtensionContext): void { + if (!vscode.workspace.workspaceFolders) return; + + vscode.workspace.workspaceFolders.forEach(folder => { + let 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); + } + }); + } + }); + }); +} + +async function selectWorkspaceFolder(context: vscode.ExtensionContext, fiddleId: string): Promise { + var selectedFolder: vscode.WorkspaceFolder; + var workspaceFolderUri: vscode.Uri; + var workspaceFolderIndex: number; + + const fiddleConfiguration = parseFiddleId(fiddleId); -async function getWorkspaceFolder(context: vscode.ExtensionContext, fiddleId: String): Promise { if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { - return await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Pick workspace folder to create files in.' }); + selectedFolder = await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Pick workspace folder to create files in.' }); + if (!selectedFolder) return null; + + workspaceFolderIndex = selectedFolder.index; + workspaceFolderUri = selectedFolder.uri; } else if (!vscode.workspace.workspaceFolders) { let folderUris = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, openLabel: 'Select folder' }); if (!folderUris) { return null; } - vscode.workspace.updateWorkspaceFolders(0, 0, { uri: folderUris[0] }); - context.globalState.update(FIDDLE_ID_TO_OPEN, fiddleId); - return null; // the extension will get reloaded in the context of the newly open workspace + + workspaceFolderUri = folderUris[0]; + // was such workspace folder already open? + workspaceFolderIndex = vscode.workspace.workspaceFolders && firstIndex(vscode.workspace.workspaceFolders, folder1 => folder1.uri.toString() === workspaceFolderUri.toString()); + + // save folder configuration + FiddleSourceControl.saveConfiguration(workspaceFolderUri, fiddleConfiguration); + + selectedFolder = null; // the extension will get reloaded in the context of the newly open workspace } else { - return vscode.workspace.workspaceFolders[0]; + selectedFolder = vscode.workspace.workspaceFolders[0]; } + + let workSpacesToReplace = workspaceFolderIndex > -1 ? 1 : 0; + if (workspaceFolderIndex < 0) workspaceFolderIndex = 0; + + // replace or insert the workspace + vscode.workspace.updateWorkspaceFolders(workspaceFolderIndex, workSpacesToReplace, { uri: workspaceFolderUri }); + + return selectedFolder; } async function clearWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder): Promise { @@ -105,3 +217,21 @@ async function clearWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder): Pr // this method is called when your extension is deactivated export function deactivate() { } + +class RepositoryPick implements vscode.QuickPickItem { + + constructor(public readonly fiddleSourceControl: FiddleSourceControl) { } + + get label(): string { + return this.fiddleSourceControl.getSourceControl().label; + } +} + +async function openDocumentInColumn(fileName: string, column: vscode.ViewColumn): Promise { + let uri = vscode.Uri.file(fileName); + + // assuming the file was saved, let's open it in a view column + let doc = await vscode.workspace.openTextDocument(uri); + + await vscode.window.showTextDocument(doc, { viewColumn: column }); +} \ No newline at end of file diff --git a/source-control-sample/src/fiddleConfiguration.ts b/source-control-sample/src/fiddleConfiguration.ts new file mode 100644 index 00000000..484d11c4 --- /dev/null +++ b/source-control-sample/src/fiddleConfiguration.ts @@ -0,0 +1,12 @@ +export class FiddleConfiguration { + readonly slug: string; + readonly version: number; +} + +export function parseFiddleId(id: string): FiddleConfiguration { + let idFragments = id.split('/'); + let fiddleSlug = idFragments[0]; + let fiddleVersion = idFragments.length > 1 ? parseInt(id.split('/')[1]) : undefined; + + return { slug: fiddleSlug, version: fiddleVersion }; +} \ No newline at end of file diff --git a/source-control-sample/src/fiddleDocumentContentProvider.ts b/source-control-sample/src/fiddleDocumentContentProvider.ts index 40cdbc1d..9368fbf7 100644 --- a/source-control-sample/src/fiddleDocumentContentProvider.ts +++ b/source-control-sample/src/fiddleDocumentContentProvider.ts @@ -1,5 +1,6 @@ import { CancellationToken, ProviderResult, TextDocumentContentProvider, Event, Uri, EventEmitter, Disposable } from "vscode"; import { toExtension, JSFIDDLE_SCHEME, Fiddle } from "./fiddleRepository"; +import { basename } from "path"; /** * Provides the content of the JS Fiddle documents as fetched from the server i.e. without the local edits. @@ -7,7 +8,7 @@ import { toExtension, JSFIDDLE_SCHEME, Fiddle } from "./fiddleRepository"; */ export class JSFiddleDocumentContentProvider implements TextDocumentContentProvider, Disposable { private _onDidChange = new EventEmitter(); - private fiddle: Fiddle; + private fiddles = new Map(); get onDidChange(): Event { return this._onDidChange.event; @@ -18,19 +19,25 @@ export class JSFiddleDocumentContentProvider implements TextDocumentContentProvi } updated(newFiddle: Fiddle): void { - this.fiddle = newFiddle; + this.fiddles.set(newFiddle.slug, newFiddle); // let's assume all 3 documents actually changed and notify the quick-diff - this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${this.fiddle.hash}.html`)); - this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${this.fiddle.hash}.css`)); - this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${this.fiddle.hash}.js`)); + this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${newFiddle.slug}.html`)); + this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${newFiddle.slug}.css`)); + this._onDidChange.fire(Uri.parse(`${JSFIDDLE_SCHEME}:${newFiddle.slug}.js`)); } provideTextDocumentContent(uri: Uri, token: CancellationToken): ProviderResult { if (token.isCancellationRequested) return "Canceled"; + let fiddleSlug = basename(uri.fsPath); + // strip off the file extension + fiddleSlug = fiddleSlug.split('.').slice(0, -1).join('.'); let fiddlePart = toExtension(uri); - return this.fiddle.data[fiddlePart]; + let fiddle = this.fiddles.get(fiddleSlug); + if (!fiddle) return "Resource not found: " + uri.toString(); + + return fiddle.data[fiddlePart]; } } \ No newline at end of file diff --git a/source-control-sample/src/fiddleRepository.ts b/source-control-sample/src/fiddleRepository.ts index b2371721..8ffee138 100644 --- a/source-control-sample/src/fiddleRepository.ts +++ b/source-control-sample/src/fiddleRepository.ts @@ -1,11 +1,13 @@ import JSFiddle = require("jsfiddle"); -import { QuickDiffProvider, Uri, CancellationToken, ProviderResult, WorkspaceFolder, workspace, TextDocument } from "vscode"; +import { QuickDiffProvider, Uri, CancellationToken, ProviderResult, WorkspaceFolder, workspace } from "vscode"; import * as path from 'path'; +/** Represents one JSFiddle data and meta-data. */ export class Fiddle { - constructor(public hash: string, public version: number, public data: FiddleData) { } + constructor(public slug: string, public version: number, public data: FiddleData) { } } +/** Represents JSFiddle HTML, JavaScript and CSS text. */ export interface FiddleData { html: string; js: string; @@ -22,56 +24,91 @@ export const JSFIDDLE_SCHEME = 'jsfiddle'; export class FiddleRepository implements QuickDiffProvider { - constructor(private workspaceFolder: WorkspaceFolder, private fiddleHash: string) { } + constructor(private workspaceFolder: WorkspaceFolder, private fiddleSlug: string) { } provideOriginalResource?(uri: Uri, token: CancellationToken): ProviderResult { // converts the local file uri to jsfiddle:file.ext let relativePath = workspace.asRelativePath(uri.fsPath); return Uri.parse(`${JSFIDDLE_SCHEME}:${relativePath}`); } + + /** + * Enumerates the resources under source control. + */ + provideSourceControlledResources(): Uri[] { + return [ + Uri.file(this.createLocalResourcePath('html')), + Uri.file(this.createLocalResourcePath('js')), + Uri.file(this.createLocalResourcePath('css')) ]; + } + + /** + * Creates a local file path in the local workspace that corresponds to the part of the + * fiddle denoted by the given extension. + * + * @param extension fiddle part, which is also used as a file extension + * @returns path of the locally cloned fiddle resource ending with the given extension + */ + createLocalResourcePath(extension: string) { + return path.join(this.workspaceFolder.uri.fsPath, this.fiddleSlug + '.' + extension); + } } -export async function downloadFiddle(hash: string, version: number | undefined): Promise { +const DEMO: FiddleData[] = [ + { + html: '
Hi
', + css: `.hi {\n color: red;\n}`, + js: '$(".hi").fadeOut();' + } +]; - if (hash === "demo") { - let maxDemoVersion = 1; +// emulates prior versions mock-committed in previous sessions +var demoVersionOffset: number = undefined; + +export async function downloadFiddle(slug: string, version: number | undefined): Promise { + + if (slug === "demo") { + // use mock fiddle + if (!demoVersionOffset) demoVersionOffset = version-1; + let maxDemoVersion = DEMO.length + demoVersionOffset; if (version === undefined) version = maxDemoVersion; - if (version <= maxDemoVersion) { - let fiddleData: FiddleData = { - html: '
Hi
', - css: `.hi {\n color: red;\n}`, - js: '$(".hi").fadeOut();' - }; - return new Fiddle(hash, version, fiddleData); + if (version >= 1 && version <= maxDemoVersion) { + // mock all versions committed in previous sessions by the first version + let index = Math.max(0, version-1 - demoVersionOffset); + let fiddleData = DEMO[index]; + return new Fiddle(slug, version, fiddleData); } else { throw "Invalid demo fiddle version."; } } - let id = toFiddleId(hash, version); + let id = toFiddleId(slug, version); return new Promise((resolve, reject) => { JSFiddle.getFiddle(id, (err: any, fiddleData: any) => { // handle error if (err) reject(err); - let fiddle = new Fiddle(hash, version, fiddleData); + let fiddle = new Fiddle(slug, version, fiddleData); resolve(fiddle); }); }); } -export async function uploadFiddle(hash: string, version: number, html: string, js: string, css: string): Promise { +export async function uploadFiddle(slug: string, version: number, html: string, js: string, css: string): Promise { - if (hash === "demo") { - return new Fiddle(hash, version, { html: html, js: js, css: css }); + if (slug === "demo") { + // using mock fiddle + let fiddleData: FiddleData = { html: html, js: js, css: css }; + DEMO.push(fiddleData); + return new Fiddle(slug, version, fiddleData); } let data = { - slug: hash, + slug: slug, version: version, html: html, js: js, @@ -83,19 +120,19 @@ export async function uploadFiddle(hash: string, version: number, html: string, // handle error if (err) reject(err); - let fiddle = new Fiddle(hash, version, fiddleData); + let fiddle = new Fiddle(slug, version, fiddleData); resolve(fiddle); }); }); } -function toFiddleId(hash: string, version: number | undefined): string { +function toFiddleId(slug: string, version: number | undefined): string { if (version === undefined) { - return hash; + return slug; } else { - return hash + '/' + version; + return slug + '/' + version; } } diff --git a/source-control-sample/src/fiddleSourceControl.ts b/source-control-sample/src/fiddleSourceControl.ts index d7f840e1..58df9075 100644 --- a/source-control-sample/src/fiddleSourceControl.ts +++ b/source-control-sample/src/fiddleSourceControl.ts @@ -1,28 +1,37 @@ import * as vscode from 'vscode'; import { FiddleRepository, toExtension, downloadFiddle, areIdentical, uploadFiddle, Fiddle } from './fiddleRepository'; import * as path from 'path'; -import { writeFileSync, existsSync, unlinkSync } from 'fs'; +import { writeFileSync, existsSync, writeFile } from 'fs'; +import { FiddleConfiguration, parseFiddleId } from './fiddleConfiguration'; + +export const CONFIGURATION_FILE = '.jsfiddle'; export class FiddleSourceControl implements vscode.Disposable { - jsFiddleScm: vscode.SourceControl; - changedResources: vscode.SourceControlResourceGroup; - fiddleRepository: FiddleRepository; - latestFiddleVersion: number = Number.POSITIVE_INFINITY; // until actual value is established + private jsFiddleScm: vscode.SourceControl; + private changedResources: vscode.SourceControlResourceGroup; + private fiddleRepository: FiddleRepository; + private latestFiddleVersion: number = Number.POSITIVE_INFINITY; // until actual value is established private _onRepositoryChange = new vscode.EventEmitter(); + private timeout: NodeJS.Timer; + private fiddle: Fiddle; - constructor(context: vscode.ExtensionContext, private workspaceFolder: vscode.WorkspaceFolder, private documents: vscode.TextDocument[], public fiddle: Fiddle) { - this.jsFiddleScm = vscode.scm.createSourceControl('jsfiddle', 'JSFiddle #' + fiddle.hash, workspaceFolder.uri); + constructor(context: vscode.ExtensionContext, private readonly workspaceFolder: vscode.WorkspaceFolder, fiddle: Fiddle, overwrite: 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.hash); + this.fiddleRepository = new FiddleRepository(workspaceFolder, fiddle.slug); this.jsFiddleScm.quickDiffProvider = this.fiddleRepository; - this.refreshStatusBar(); + this.jsFiddleScm.inputBox.placeholder = 'Message is ignored by JS Fiddle :-]'; + + let fileSystemWatcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(workspaceFolder, "*.*")); + fileSystemWatcher.onDidChange(uri => this.onResourceChange(uri), context.subscriptions); + fileSystemWatcher.onDidCreate(uri => this.onResourceChange(uri), context.subscriptions); + fileSystemWatcher.onDidDelete(uri => this.onResourceChange(uri), context.subscriptions); - context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(e => this.updateChangedGroup(e))); context.subscriptions.push(this.jsFiddleScm); - context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.refresh", () => this.refresh())); - context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.discard", () => this.resetFilesToCheckedOutVersion())); - context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.commit", () => this.commitAll())); - context.subscriptions.push(vscode.commands.registerCommand("extension.source-control.checkout", newVersion => this.tryCheckout(newVersion))); + context.subscriptions.push(fileSystemWatcher); + + // clone fiddle to the local workspace + this.setFiddle(fiddle, overwrite); if (Number.isNaN(this.fiddle.version)) { this.establishVersion(); @@ -31,54 +40,62 @@ export class FiddleSourceControl implements vscode.Disposable { } } - static async fromFiddle(id: string, context: vscode.ExtensionContext, workspaceFolder: vscode.WorkspaceFolder): Promise { - let idFragments = id.split('/'); - let fiddleHash = idFragments[0]; - let fiddleVersion = idFragments.length > 1 ? parseInt(id.split('/')[1]) : undefined; + static async fromFiddleId(id: string, context: vscode.ExtensionContext, workspaceFolder: vscode.WorkspaceFolder, overwrite: boolean): Promise { + let fiddleConfiguration = parseFiddleId(id); - let fiddle = await downloadFiddle(fiddleHash, fiddleVersion); + return await FiddleSourceControl.fromConfiguration(fiddleConfiguration, workspaceFolder, context, overwrite); + } + static async fromConfiguration(configuration: FiddleConfiguration, workspaceFolder: vscode.WorkspaceFolder, context: vscode.ExtensionContext, overwrite: boolean): Promise { + return await FiddleSourceControl.fromFiddle(configuration.slug, configuration.version, workspaceFolder, context, overwrite); + } + + private static async fromFiddle(fiddleSlug: string, fiddleVersion: number, workspaceFolder: vscode.WorkspaceFolder, context: vscode.ExtensionContext, overwrite: boolean): Promise { + let fiddle = await downloadFiddle(fiddleSlug, fiddleVersion); let workspacePath = workspaceFolder.uri.fsPath; - - let documents = [ - await createDocument(fiddle.data.html, path.join(workspacePath, fiddleHash + '.html'), vscode.ViewColumn.One), - await createDocument(fiddle.data.js, path.join(workspacePath, fiddleHash + '.js'), vscode.ViewColumn.Two), - await createDocument(fiddle.data.css, path.join(workspacePath, fiddleHash + '.css'), vscode.ViewColumn.Three) - ]; - - return new FiddleSourceControl(context, workspaceFolder, documents, fiddle); + return new FiddleSourceControl(context, workspaceFolder, fiddle, overwrite); } private refreshStatusBar() { this.jsFiddleScm.statusBarCommands = [ { "command": "extension.source-control.checkout", - "title": `↕ ${this.fiddle.hash} #${this.fiddle.version} / ${this.latestFiddleVersion}`, + "arguments": [this], + "title": `↕ ${this.fiddle.slug} #${this.fiddle.version} / ${this.latestFiddleVersion}`, "tooltip": "Checkout another version of this fiddle.", } ]; } async commitAll(): Promise { - if (this.fiddle.version < this.latestFiddleVersion) { - vscode.window.showErrorMessage("Checkout the latest fiddle version before committing your chanes."); + if (!this.changedResources.resourceStates.length) { + vscode.window.showErrorMessage("There is nothing to commit."); + } + else if (this.fiddle.version < this.latestFiddleVersion) { + vscode.window.showErrorMessage("Checkout the latest fiddle version before committing your changes."); } else { - let answer = await vscode.window.showQuickPick(["Yes, upload the changes to JS Fiddle.", "No, I was just clicking around."], + let answer = await vscode.window.showQuickPick(["Yes, upload the changes to JS Fiddle.", "No, I was just clicking around."], { placeHolder: "Are you sure you want to commit?" }); if (answer && answer.toLowerCase().startsWith("yes")) { - let html = this.documents.find(doc => path.extname(doc.fileName) == ".html").getText(); - let js = this.documents.find(doc => path.extname(doc.fileName) == ".js").getText(); - let css = this.documents.find(doc => path.extname(doc.fileName) == ".css").getText(); + let html = await this.getLocalResourceText('html'); + let js = await this.getLocalResourceText('js'); + let css = await this.getLocalResourceText('css'); // here we assume nobody updated the Fiddle on the server since we refreshed the list of versions - let newFiddle = await uploadFiddle(this.fiddle.hash, this.fiddle.version + 1, html, js, css); - this.setFiddle(newFiddle); + let newFiddle = await uploadFiddle(this.fiddle.slug, this.fiddle.version + 1, html, js, css); + this.setFiddle(newFiddle, false); + this.jsFiddleScm.inputBox.value = ''; } } } + private async getLocalResourceText(extension: string) { + let document = await vscode.workspace.openTextDocument(this.fiddleRepository.createLocalResourcePath(extension)); + return document.getText(); + } + /** * Throws away all local changes and resets all files to the checked out version of the repository. */ @@ -88,13 +105,14 @@ export class FiddleSourceControl implements vscode.Disposable { this.resetFile('js'); } - private resetFile(extension: string) { - let filePath = path.join(this.workspaceFolder.uri.fsPath, this.fiddle.hash + '.' + extension); + /** Resets the given local file content to the checked-out version. */ + private resetFile(extension: string): void { + let filePath = this.fiddleRepository.createLocalResourcePath(extension); writeFileSync(filePath, this.fiddle.data[extension]); } async tryCheckout(newVersion: number | undefined): Promise { - if (!Number.isFinite(this.latestFiddleVersion)) return void 0; + if (!Number.isFinite(this.latestFiddleVersion)) return; if (newVersion === undefined) { let allVersions = [...Array(this.latestFiddleVersion).keys()] @@ -105,7 +123,7 @@ export class FiddleSourceControl implements vscode.Disposable { newVersion = newVersionPick.version; } else { - return void 0; + return; } } @@ -115,20 +133,55 @@ export class FiddleSourceControl implements vscode.Disposable { } else { try { - let newFiddle = await downloadFiddle(this.fiddle.hash, newVersion); - this.setFiddle(newFiddle); + let newFiddle = await downloadFiddle(this.fiddle.slug, newVersion); + this.setFiddle(newFiddle, true); } catch (ex) { vscode.window.showErrorMessage(ex); } } } - private setFiddle(newFiddle: Fiddle) { + private setFiddle(newFiddle: Fiddle, overwrite: boolean) { if (newFiddle.version > this.latestFiddleVersion) this.latestFiddleVersion = newFiddle.version; this.fiddle = newFiddle; - this.resetFilesToCheckedOutVersion(); // overwrite local file content + if (overwrite) this.resetFilesToCheckedOutVersion(); // overwrite local file content this._onRepositoryChange.fire(this.fiddle); this.refreshStatusBar(); + + this.saveCurrentConfiguration(); + } + + getFiddle(): Fiddle { + return this.fiddle; + } + + getWorkspaceFolder(): vscode.WorkspaceFolder { + return this.workspaceFolder; + } + + getSourceControl(): vscode.SourceControl { + return this.jsFiddleScm; + } + + getRepository(): FiddleRepository { + return this.fiddleRepository; + } + + /** save configuration for later VS Code sessions */ + private saveCurrentConfiguration(): void { + let fiddleConfiguration: FiddleConfiguration = { + slug: this.fiddle.slug, + version: this.fiddle.version + }; + + FiddleSourceControl.saveConfiguration(this.workspaceFolder.uri, fiddleConfiguration); + } + + 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); + }); } /** @@ -140,7 +193,7 @@ export class FiddleSourceControl implements vscode.Disposable { while (true) { try { latestVersion++; - let latestFiddle = await downloadFiddle(this.fiddle.hash, latestVersion); + let latestFiddle = await downloadFiddle(this.fiddle.slug, latestVersion); } catch (ex) { // typically the ex.statusCode == 404, when there is no further version break; @@ -160,7 +213,7 @@ export class FiddleSourceControl implements vscode.Disposable { while (true) { try { latestVersion++; - let latestFiddle = await downloadFiddle(this.fiddle.hash, latestVersion); + let latestFiddle = await downloadFiddle(this.fiddle.slug, latestVersion); if (areIdentical(this.fiddle.data, latestFiddle.data)) { currentFiddle = latestFiddle; } @@ -173,7 +226,7 @@ export class FiddleSourceControl implements vscode.Disposable { this.latestFiddleVersion = latestVersion - 1; // now we know the version of the current fiddle, let's set it - this.setFiddle(currentFiddle); + this.setFiddle(currentFiddle, false); } @@ -181,35 +234,85 @@ export class FiddleSourceControl implements vscode.Disposable { return this._onRepositoryChange.event; } - updateChangedGroup(e: vscode.TextDocumentChangeEvent): any { - this.changedResources.resourceStates = this.documents - .filter(doc => this.isDirty(doc)) - .map(doc => this.toSourceControlResourceState(doc)); + onResourceChange(_uri: vscode.Uri): void { + if (this.timeout) clearTimeout(this.timeout); + this.timeout = setTimeout(() => this.tryUpdateChangedGroup(), 500); } + async tryUpdateChangedGroup(): Promise { + try { + await this.updateChangedGroup(); + } + catch (ex) { + vscode.window.showErrorMessage(ex); + } + } + + async updateChangedGroup(): Promise { + // for simplicity we ignore which document was changed in this event and scan all of them + let changedResources: vscode.SourceControlResourceState[] = []; + + let uris = this.fiddleRepository.provideSourceControlledResources(); + + for (const uri of uris) { + let isDirty: boolean; + let wasDeleted: boolean; + + if (existsSync(uri.fsPath)) { + let document = await vscode.workspace.openTextDocument(uri); + isDirty = this.isDirty(document); + wasDeleted = false; + } + else { + isDirty = true; + wasDeleted = true; + } + + if (isDirty) { + let resourceState = this.toSourceControlResourceState(uri, wasDeleted); + changedResources.push(resourceState); + } + } + + this.changedResources.resourceStates = changedResources; + } + + /** Determines whether the resource is different, regardless of line endings. */ isDirty(doc: vscode.TextDocument): boolean { let originalText = this.fiddle.data[toExtension(doc.uri)]; return originalText.replace('\r', '') != doc.getText().replace('\r', ''); } - toSourceControlResourceState(doc: vscode.TextDocument): vscode.SourceControlResourceState { + toSourceControlResourceState(docUri: vscode.Uri, deleted: boolean): vscode.SourceControlResourceState { - let repositoryUri = this.fiddleRepository.provideOriginalResource(doc.uri, null); + let repositoryUri = this.fiddleRepository.provideOriginalResource(docUri, null); - const fiddlePart = toExtension(doc.uri).toUpperCase(); - return { - resourceUri: doc.uri, - command: { + const fiddlePart = toExtension(docUri).toUpperCase(); + + let command: vscode.Command = !deleted + ? { title: "Show changes", command: "vscode.diff", - arguments: [repositoryUri, doc.uri, `JSFiddle#${this.fiddle.hash} ${fiddlePart} ↔ Local changes`], + arguments: [repositoryUri, docUri, `JSFiddle#${this.fiddle.slug} ${fiddlePart} ↔ Local changes`], tooltip: "Diff your changes" } + : null; + + let resourceState: vscode.SourceControlResourceState = { + resourceUri: docUri, + command: command, + decorations: { + strikeThrough: deleted, + tooltip: 'File was locally deleted.' + } }; + + return resourceState; } dispose() { this._onRepositoryChange.dispose(); + this.jsFiddleScm.dispose(); } } @@ -228,23 +331,3 @@ class VersionQuickPickItem implements vscode.QuickPickItem { } } } - - -async function createDocument(content: string, fileName: string, column: vscode.ViewColumn): Promise { - if (existsSync(fileName)) { - unlinkSync(fileName); - } - let fileUri = vscode.Uri.file(fileName).with({scheme: 'untitled'}); - let doc = await vscode.workspace.openTextDocument(fileUri); - - let edit = new vscode.WorkspaceEdit(); - edit.insert(doc.uri, new vscode.Position(0, 0), content); - await vscode.workspace.applyEdit(edit); - await doc.save(); - - // now that the document is saved, let's get the document through the 'file' schema, not 'untitled' - doc = await vscode.workspace.openTextDocument(vscode.Uri.file(fileName)); - - await vscode.window.showTextDocument(doc, { viewColumn: column}); - return doc; -} diff --git a/source-control-sample/src/util.ts b/source-control-sample/src/util.ts new file mode 100644 index 00000000..9b17400b --- /dev/null +++ b/source-control-sample/src/util.ts @@ -0,0 +1,9 @@ +export function firstIndex(array: T[], fn: (t: T) => boolean): number { + for (let i = 0; i < array.length; i++) { + if (fn(array[i])) { + return i; + } + } + + return -1; +}