import * as vscode from 'vscode'; import { FiddleRepository, toExtension, downloadFiddle, areIdentical, uploadFiddle, Fiddle } from './fiddleRepository'; import * as path from 'path'; import { writeFileSync, existsSync, writeFile } from 'fs'; import { FiddleConfiguration, parseFiddleId } from './fiddleConfiguration'; export const CONFIGURATION_FILE = '.jsfiddle'; export class FiddleSourceControl implements vscode.Disposable { 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 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.slug); this.jsFiddleScm.quickDiffProvider = this.fiddleRepository; 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(this.jsFiddleScm); context.subscriptions.push(fileSystemWatcher); // clone fiddle to the local workspace this.setFiddle(fiddle, overwrite); if (Number.isNaN(this.fiddle.version)) { this.establishVersion(); } else { this.refresh(); } } static async fromFiddleId(id: string, context: vscode.ExtensionContext, workspaceFolder: vscode.WorkspaceFolder, overwrite: boolean): Promise { let fiddleConfiguration = parseFiddleId(id); 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; return new FiddleSourceControl(context, workspaceFolder, fiddle, overwrite); } private refreshStatusBar() { this.jsFiddleScm.statusBarCommands = [ { "command": "extension.source-control.checkout", "arguments": [this], "title": `↕ ${this.fiddle.slug} #${this.fiddle.version} / ${this.latestFiddleVersion}`, "tooltip": "Checkout another version of this fiddle.", } ]; } async commitAll(): Promise { 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."], { placeHolder: "Are you sure you want to commit?" }); if (answer && answer.toLowerCase().startsWith("yes")) { 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.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. */ resetFilesToCheckedOutVersion(): void { this.resetFile('html'); this.resetFile('css'); this.resetFile('js'); } /** 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; if (newVersion === undefined) { let allVersions = [...Array(this.latestFiddleVersion).keys()] .map(n => n + 1) .map(ver => new VersionQuickPickItem(ver, ver == this.fiddle.version)); let newVersionPick = await vscode.window.showQuickPick(allVersions, { canPickMany: false, placeHolder: 'Select a version...' }); if (newVersionPick) { newVersion = newVersionPick.version; } else { return; } } if (this.changedResources.resourceStates.length) { let changedResourcesCount = this.changedResources.resourceStates.length; vscode.window.showErrorMessage(`There is one or more changed resources. Discard or commit your local changes before checking out another version.`); } else { try { let newFiddle = await downloadFiddle(this.fiddle.slug, newVersion); this.setFiddle(newFiddle, true); } catch (ex) { vscode.window.showErrorMessage(ex); } } } private setFiddle(newFiddle: Fiddle, overwrite: boolean) { if (newFiddle.version > this.latestFiddleVersion) this.latestFiddleVersion = newFiddle.version; this.fiddle = newFiddle; 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); }); } /** * Refresh is used when the information on the server may have changed. * For example another user updates the Fiddle online. */ async refresh(): Promise { let latestVersion = this.fiddle.version; while (true) { try { latestVersion++; let latestFiddle = await downloadFiddle(this.fiddle.slug, latestVersion); } catch (ex) { // typically the ex.statusCode == 404, when there is no further version break; } } this.latestFiddleVersion = latestVersion - 1; this.refreshStatusBar(); } /** * Determines which version was checked out and finds the index of the latest version. */ async establishVersion(): Promise { let latestVersion = 0; let currentFiddle: Fiddle = undefined; while (true) { try { latestVersion++; let latestFiddle = await downloadFiddle(this.fiddle.slug, latestVersion); if (areIdentical(this.fiddle.data, latestFiddle.data)) { currentFiddle = latestFiddle; } } catch (ex) { // typically the ex.statusCode == 404, when there is no further version break; } } this.latestFiddleVersion = latestVersion - 1; // now we know the version of the current fiddle, let's set it this.setFiddle(currentFiddle, false); } get onRepositoryChange(): vscode.Event { return this._onRepositoryChange.event; } 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(docUri: vscode.Uri, deleted: boolean): vscode.SourceControlResourceState { let repositoryUri = this.fiddleRepository.provideOriginalResource(docUri, null); const fiddlePart = toExtension(docUri).toUpperCase(); let command: vscode.Command = !deleted ? { title: "Show changes", command: "vscode.diff", 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(); } } class VersionQuickPickItem implements vscode.QuickPickItem { label: string; description?: string; detail?: string; alwaysShow?: boolean; constructor(public version: number, public picked: boolean) { this.label = this.version.toString(); this.alwaysShow = picked; if (picked) { this.description = "Currently checked out."; } } }