Repository: electron/fiddle (ts)

Issue:
Add a reliable way to copy the current gist URL to clipboard
1. Open Fiddle
2. Publish
3. Delete the URL next to "Load Fiddle"
4. Make some changes and publish again
5. How do I copy the gist URL to the clipboard now?

When publishing/updating gists the url should be copied to the clipboard at the very least.

There should probably also be a menu item for copying the url to the current gist.

Pull request description:
fix: repopulate URL bar on blur
Once the user manipulates the <code>address bar</code> value and publishes the fiddle again , the gist url is no longer available .

<strong>Fix</strong> :

Added a <code>onBlur</code> event to <code>address bar</code> , which loads back the original gist url if the user looses focus out of the bar .

Fixes #427

Would love an review @erickzhao :)

Patch:
diff --git a/package.json b/package.json
index d1620058..bc52b534 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
     "@octokit/rest": "^16.43.1",
     "@sentry/electron": "^2.0.1",
     "classnames": "^2.2.6",
+    "decomment": "^0.9.3",
     "electron-default-menu": "^1.0.2",
     "electron-devtools-installer": "^3.1.1",
     "electron-squirrel-startup": "^1.0.0",
@@ -71,6 +72,7 @@
     "@octokit/action": "^2.0.0",
     "@types/classnames": "^2.2.10",
     "@types/enzyme": "^3.10.7",
+    "@types/decomment": "^0.9.0",
     "@types/fs-extra": "^9.0.2",
     "@types/jest": "^26.0.14",
     "@types/log-symbols": "^3.0.0",
diff --git a/src/main/windows.ts b/src/main/windows.ts
index 08c56df0..54b0231c 100644
--- a/src/main/windows.ts
+++ b/src/main/windows.ts
@@ -25,6 +25,7 @@ export function getMainWindowOptions(): Electron.BrowserWindowConstructorOptions
       webviewTag: false,
       nodeIntegration: true,
       enableRemoteModule: true,
+      nodeIntegrationInWorker: true,
     },
   };
 }
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 808d55e8..ffa9db2f 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -71,11 +71,28 @@ export class App {
 
     // display all editors that have content
     const visibleEditors = [];
-    for (const id of Object.keys(editorValues)) {
+
+    // ensure consistent sorting of mosaics
+    const sortedEditors = Object.keys(editorValues).sort((a, b) => {
+      const order: Array<string> = [
+        'main',
+        'renderer',
+        'html',
+        'preload',
+        'css',
+      ];
+
+      return order.indexOf(a) - order.indexOf(b);
+    });
+
+    for (const id of sortedEditors) {
       const content = editorValues[id];
 
-      // if the gist content matches the empty file output, don't show it
-      if (!Object.values(EMPTY_EDITOR_CONTENT).includes(content)) {
+      // if the gist content is empty or matches the empty file output, don't show it
+      if (
+        content.length > 0 &&
+        !Object.values(EMPTY_EDITOR_CONTENT).includes(content)
+      ) {
         visibleEditors.push(id as EditorId);
       }
     }
diff --git a/src/renderer/components/commands-address-bar.tsx b/src/renderer/components/commands-address-bar.tsx
index b90344ec..746317c0 100644
--- a/src/renderer/components/commands-address-bar.tsx
+++ b/src/renderer/components/commands-address-bar.tsx
@@ -31,6 +31,7 @@ export class AddressBar extends React.Component<
     super(props);
     this.handleSubmit = this.handleSubmit.bind(this);
     this.handleChange = this.handleChange.bind(this);
+    this.handleBlur = this.handleBlur.bind(this);
     this.submit = this.submit.bind(this);
 
     const { gistId } = this.props.appState;
@@ -110,6 +111,15 @@ export class AddressBar extends React.Component<
     this.setState({ value: event.target.value });
   }
 
+  public handleBlur(_event: React.FocusEvent<HTMLInputElement>) {
+    const { gistId } = this.props.appState;
+    const url = urlFromId(gistId);
+
+    if (url) {
+      this.setState({ value: url });
+    }
+  }
+
   public renderLoadButton(isValueCorrect: boolean): JSX.Element {
     return (
       <Button
@@ -136,6 +146,7 @@ export class AddressBar extends React.Component<
             leftIcon="geosearch"
             intent={isCorrect || !value ? undefined : Intent.DANGER}
             onChange={this.handleChange}
+            onBlur={this.handleBlur}
             placeholder="https://gist.github.com/..."
             value={value}
             rightElement={this.renderLoadButton(isCorrect)}
diff --git a/src/renderer/npm.ts b/src/renderer/npm.ts
index f1737977..837e676d 100644
--- a/src/renderer/npm.ts
+++ b/src/renderer/npm.ts
@@ -1,7 +1,7 @@
 import { EditorValues } from '../interfaces';
 import { exec } from '../utils/exec';
 
-const { builtinModules } = require('module');
+import { builtinModules } from 'module';
 
 export type IPackageManager = 'npm' | 'yarn';
 
@@ -22,7 +22,7 @@ const ignoredModules: Array<string> = [
 ];
 
 /* regular expression to both match and extract module names */
-const requiregx = /^.*require\(['"](.*?)['"]\)/gm;
+const requiregx = /require\(['"](.*?)['"]\)/gm;
 
 /*
  Quick and dirty filter functions for filtering module names
@@ -72,24 +72,22 @@ export async function getIsPackageManagerInstalled(
 
 /**
  * Finds npm modules in editor values, returning an array of modules.
- *
- * @param {EditorValues} values
- * @returns {Array<string>}
  */
-export function findModulesInEditors(values: EditorValues) {
-  const files = [values.main, values.renderer];
-  const modules = files.reduce(
-    (agg, file) => [...agg, ...findModules(file)],
-    [],
-  );
+export async function findModulesInEditors(values: EditorValues) {
+  const files = [values.main, values.renderer, values.preload];
+  const modules: Array<string> = [];
+
+  for (const file of files) {
+    modules.push(...(await findModules(file)));
+  }
 
   console.log('Modules Found:', modules);
 
-  return modules;
+  return Array.from(new Set(modules));
 }
 
 /**
- * Uses a simple regex to find `require()` statements in a string.
+ * Finds `require()` statements in a string.
  * Tries to exclude electron and Node built-ins as well as file-path
  * references. Also will try to install base packages of modules
  * that have a slash in them, for example: `lodash/fp` as the actual package
@@ -101,18 +99,18 @@ export function findModulesInEditors(values: EditorValues) {
  * @param {string} input
  * @returns {Array<string>}
  */
-export function findModules(input: string): Array<string> {
+export async function findModules(input: string) {
   /* container definitions */
   const modules: Array<string> = [];
   let match: RegExpMatchArray | null;
 
+  /* decomment code with the esprima parser */
+  const code = await decommentWithWorker(input);
+
   /* grab all global require matches in the text */
-  while ((match = requiregx.exec(input) || null)) {
-    // ensure commented-out requires aren't downloaded
-    if (!match[0].startsWith('//')) {
-      const mod = match[1];
-      modules.push(mod);
-    }
+  while ((match = requiregx.exec(code) || null)) {
+    const mod = match[1];
+    modules.push(mod);
   }
 
   /* map and reduce */
@@ -162,3 +160,16 @@ export function packageRun(
 ): Promise<string> {
   return exec(dir, `${packageManager} run ${command}`);
 }
+
+export function decommentWithWorker(input: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const worker = new Worker('../utils/decomment.ts');
+    worker.onmessage = function (event: MessageEvent<string>) {
+      resolve(event.data);
+    };
+    worker.postMessage(input);
+    worker.onerror = function (e) {
+      reject(e);
+    };
+  });
+}
diff --git a/src/utils/decomment.ts b/src/utils/decomment.ts
new file mode 100644
index 00000000..4f683b3c
--- /dev/null
+++ b/src/utils/decomment.ts
@@ -0,0 +1,5 @@
+import decomment from 'decomment';
+
+onmessage = function (event: MessageEvent<string>) {
+  postMessage(decomment(event.data));
+};
diff --git a/tsconfig.json b/tsconfig.json
index d605a9ca..1d5bd5d6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,8 @@
     "sourceMap": true,
     "lib": [
       "es2017",
-      "dom"
+      "dom",
+      "WebWorker"
     ],
     "noImplicitAny": true,
     "noImplicitReturns": true,
@@ -41,4 +42,4 @@
     "indentSize": 2,
     "tabSize": 2
   }
-}
\ No newline at end of file
+}
diff --git a/yarn.lock b/yarn.lock
index 4b443b84..59b21123 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1924,6 +1924,11 @@
   resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
   integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
 
+"@types/decomment@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@types/decomment/-/decomment-0.9.0.tgz#ef4701e474eef774e9ddd65e4e85f6c3dab5ce9f"
+  integrity sha512-3K8zqMlLQREJtIMboTH9GyuX/CSL5cFg+B1gVezUdEJ9lm8IPIJzO+WH0L1sQnkHIdU8Wz65rzfrXb+NojWr1Q==
+
 "@types/dom4@^2.0.1":
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/@types/dom4/-/dom4-2.0.1.tgz#506d5781b9bcab81bd9a878b198aec7dee2a6033"
@@ -3974,6 +3979,13 @@ decode-uri-component@^0.2.0:
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
   integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
 
+decomment@^0.9.3:
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/decomment/-/decomment-0.9.3.tgz#b913f32e5fe1113848f516caa5c7afefa9544d38"
+  integrity sha512-5skH5BfUL3n09RDmMVaHS1QGCiZRnl2nArUwmsE9JRY93Ueh3tihYl5wIrDdAuXnoFhxVis/DmRWREO2c6DG3w==
+  dependencies:
+    esprima "4.0.1"
+
 decompress-response@^3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
@@ -4982,16 +4994,16 @@ espree@^7.3.0:
     acorn-jsx "^5.2.0"
     eslint-visitor-keys "^1.3.0"
 
+esprima@4.0.1, esprima@^4.0.0, esprima@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
+
 esprima@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
   integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
 
-esprima@^4.0.0, esprima@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
-  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
-
 esquery@^1.0.1, esquery@^1.2.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57"


Test patch:
diff --git a/tests/main/windows-spec.ts b/tests/main/windows-spec.ts
index e6aadb10..79df2f91 100644
--- a/tests/main/windows-spec.ts
+++ b/tests/main/windows-spec.ts
@@ -35,6 +35,7 @@ describe('windows', () => {
         webviewTag: false,
         enableRemoteModule: true,
         nodeIntegration: true,
+        nodeIntegrationInWorker: true,
       },
     };
 
diff --git a/tests/mocks/worker.ts b/tests/mocks/worker.ts
new file mode 100644
index 00000000..9305a742
--- /dev/null
+++ b/tests/mocks/worker.ts
@@ -0,0 +1,20 @@
+import * as decomment from 'decomment';
+
+export default class MockWorker {
+  public onmessage: (msg: any) => any;
+  public onmessageerror: () => any;
+  public onerror: () => any;
+  public addEventListener: () => any;
+  public removeEventListener: () => any;
+  public dispatchEvent: () => any;
+  public terminate: () => any;
+  constructor() {
+    this.onmessage = () => {
+      /*no-op*/
+    };
+  }
+
+  public postMessage(input: string) {
+    this.onmessage({ data: decomment(input) });
+  }
+}
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index a5bdc23e..c2cca04f 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -4,6 +4,7 @@ import { EditorBackup } from '../../src/utils/editor-backup';
 import { ElectronFiddleMock } from '../mocks/electron-fiddle';
 import { MockState } from '../mocks/state';
 import { overridePlatform, resetPlatform } from '../utils';
+import { EditorId } from '../../src/interfaces';
 
 jest.mock('../../src/renderer/file-manager', () =>
   require('../mocks/file-manager'),
@@ -110,18 +111,24 @@ describe('Editors component', () => {
   });
 
   describe('replaceFiddle()', () => {
-    it('sets editor values and source info', (done) => {
-      const app = new App();
+    let app: App;
+
+    beforeEach(() => {
+      app = new App();
       (app.state as Partial<AppState>) = new MockState();
       app.state.isUnsaved = false;
+      app.state.setGenericDialogOptions = jest.fn();
       app.state.setVisibleMosaics = jest.fn();
       app.setEditorValues = jest.fn();
+    });
 
+    it('sets editor values and source info', (done) => {
       const editorValues = {
         html: 'html-value',
         main: 'main-value',
         renderer: 'renderer-value',
         preload: '',
+        css: '',
       };
 
       app
@@ -139,14 +146,55 @@ describe('Editors component', () => {
         });
     });
 
+    it('only shows mosaic for non-empty editor contents', (done) => {
+      const editorValues = {
+        main: 'main-value',
+        renderer: 'renderer-value',
+        html: 'html-value',
+        preload: '',
+        css: '/* Empty */',
+      };
+
+      app
+        .replaceFiddle(editorValues, {
+          gistId: 'gistId',
+        })
+        .then(() => {
+          expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
+            EditorId.main,
+            EditorId.renderer,
+            EditorId.html,
+          ]);
+          done();
+        });
+    });
+
+    it('shows visible mosaics in the correct pre-defined order', (done) => {
+      // this order is defined inside the replaceFiddle() function
+      const editorValues = {
+        css: 'css-value',
+        html: 'html-value',
+        renderer: 'renderer-value',
+        main: 'main-value',
+      };
+      app
+        .replaceFiddle(editorValues, {
+          gistId: 'gistId',
+        })
+        .then(() => {
+          expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
+            EditorId.main,
+            EditorId.renderer,
+            EditorId.html,
+            EditorId.css,
+          ]);
+          done();
+        });
+    });
+
     it('unsets state of previous source when called', (done) => {
-      const app = new App();
-      (app.state as Partial<AppState>) = new MockState();
       app.state.isUnsaved = true;
       app.state.localPath = '/fake/path';
-      app.state.setGenericDialogOptions = jest.fn();
-      app.state.setVisibleMosaics = jest.fn();
-      app.setEditorValues = jest.fn();
 
       const editorValues = {
         html: 'html-value',
@@ -173,12 +221,6 @@ describe('Editors component', () => {
     });
 
     it('marks the new Fiddle as Saved', (done) => {
-      const app = new App();
-      (app.state as Partial<AppState>) = new MockState();
-      app.state.isUnsaved = false;
-      app.state.setVisibleMosaics = jest.fn();
-      app.setEditorValues = jest.fn();
-
       const editorValues = {
         html: 'html-value',
         main: 'main-value',
@@ -196,15 +238,10 @@ describe('Editors component', () => {
           done();
         });
     });
+
     describe('when current Fiddle is unsaved and prompt appears', () => {
       it('takes no action if prompt is rejected', (done) => {
-        const app = new App();
-        (app.state as Partial<AppState>) = new MockState();
         app.state.isUnsaved = true;
-        app.state.setVisibleMosaics = jest.fn();
-        app.state.setGenericDialogOptions = jest.fn();
-        app.setEditorValues = jest.fn();
-
         expect(app.state.localPath).toBeUndefined();
         expect(app.state.gistId).toBe('');
         expect(app.state.templateName).toBeUndefined();
diff --git a/tests/renderer/components/__snapshots__/commands-address-bar-spec.tsx.snap b/tests/renderer/components/__snapshots__/commands-address-bar-spec.tsx.snap
index 8ba0b485..444de4e7 100644
--- a/tests/renderer/components/__snapshots__/commands-address-bar-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/commands-address-bar-spec.tsx.snap
@@ -11,6 +11,7 @@ exports[`AddressBar component renders 1`] = `
     <Blueprint3.InputGroup
       key="addressbar"
       leftIcon="geosearch"
+      onBlur={[Function]}
       onChange={[Function]}
       placeholder="https://gist.github.com/..."
       rightElement={
diff --git a/tests/renderer/npm-spec.ts b/tests/renderer/npm-spec.ts
index 6fe019c3..5180478f 100644
--- a/tests/renderer/npm-spec.ts
+++ b/tests/renderer/npm-spec.ts
@@ -1,4 +1,8 @@
+import { mocked } from 'ts-jest/utils';
+import * as decomment from 'decomment';
+
 import {
+  findModules,
   findModulesInEditors,
   getIsPackageManagerInstalled,
   installModules,
@@ -6,7 +10,8 @@ import {
 } from '../../src/renderer/npm';
 import { exec } from '../../src/utils/exec';
 import { overridePlatform, resetPlatform } from '../utils';
-
+import MockDecommentWorker from '../mocks/worker';
+jest.mock('decomment');
 jest.mock('../../src/utils/exec');
 jest.mock('../../src/utils/import', () => ({
   fancyImport: async (_p: string) => {
@@ -14,10 +19,10 @@ jest.mock('../../src/utils/import', () => ({
   },
 }));
 
-describe('npm', () => {
-  const mockMain = `
-    const say = require('say');
+window.Worker = MockDecommentWorker;
 
+describe('npm', () => {
+  const mockBuiltins = `
     function hello() {
       const electron = require('electron');
       const originalFs = require('original-fs');
@@ -26,6 +31,20 @@ describe('npm', () => {
     }
   `;
 
+  const mockPackages = `
+    const cow = require('cow');
+    const say = require('say');
+  `;
+
+  const mockComments = `
+    // const cow = require('cow');
+    /* const say = require('say'); */
+    /**
+     * const hello = require('hello'); 
+     * const world = require('world'); 
+    */
+  `;
+
   describe('getIsPackageManagerInstalled()', () => {
     describe('npm()', () => {
       beforeEach(() => {
@@ -150,17 +169,39 @@ describe('npm', () => {
     });
   });
 
+  describe('findModules()', () => {
+    it('returns required modules in a JS file', async () => {
+      mocked(decomment).mockReturnValue(mockPackages);
+      const modules = await findModules(mockPackages);
+      expect(modules).toEqual(['cow', 'say']);
+    });
+
+    it('ignores node and electron builtins', async () => {
+      mocked(decomment).mockReturnValue(mockBuiltins);
+      const modules = await findModules(mockBuiltins);
+      expect(modules).toHaveLength(0);
+    });
+
+    it('ignores commented modules', async () => {
+      mocked(decomment).mockReturnValue('');
+      const modules = await findModules(mockComments);
+      expect(modules).toHaveLength(0);
+    });
+  });
+
   describe('findModulesInEditors()', () => {
-    it('finds modules, ignoring node and electron builtins', async () => {
+    it('installs modules across all JavaScript files only once', async () => {
+      mocked(decomment).mockReturnValue(mockPackages);
       const result = await findModulesInEditors({
         html: '',
-        main: mockMain,
-        renderer: '',
-        preload: '',
+        main: mockPackages,
+        renderer: mockPackages,
+        preload: mockPackages,
         css: '',
       });
 
-      expect(result).toEqual(['say']);
+      expect(result).toHaveLength(2);
+      expect(result).toEqual(['cow', 'say']);
     });
   });
 
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 3c6a8b14..2f7220b8 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -74,6 +74,7 @@ describe('AppState', () => {
 
   beforeEach(() => {
     appState = new AppState();
+    appState.updateDownloadedVersionState = jest.fn();
     ipcRendererManager.removeAllListeners();
   });
 
@@ -475,6 +476,8 @@ describe('AppState', () => {
 
   describe('updateDownloadedVersionState()', () => {
     beforeEach(() => {
+      appState = new AppState();
+      ipcRendererManager.removeAllListeners();
       (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
       (getDownloadedVersions as jest.Mock).mockReturnValue(
         Promise.resolve(['2.0.2']),