Repository: electron/fiddle
Pull request #673 (2021-04-28 20:40:50)

refactor: add editor-utils
Part 1 of breaking https://github.com/electron/fiddle/pull/664 into smaller, human-reviewable pieces.

This part lays a little bit of groundwork by adding editor-utils, which is a collection of helpers that can be used to decouple the rest of the code from `DefaultEditorId`s, `SORTED_EDITORS`, `DEFAULT_EDITORS`, and so on. But for now, their main use lies in making some existing pieces easier to use:

1. `getEmptyContent()` is a slightly friendlier form of `EMPTY_CONTENT` as clients no longer have to extract the file suffix out manually.
2. `getEditorTitle()` is a slightly friendlier form of `TITLE_MAP` as clients no longer have to handle the default editors & custom editors logic separately.
3. `isKnownFile()` returns true if it's one of the well-known files e.g. main.js or renderer.js. This is a slightly friendlier form of `Object.values(DefaultEditorId).includes()`
4. `isSupportedFile()` returns true if the file is js, html, or css. This isn't a new idea; it's just consolidated in once place so that there aren't duplicate implementations.
5. `compareEditors()` sorts the editors into mosaic order using SORTED_EDITORS order for the known editors, and then the rest are sorted lexicographically. Used by App when building the mosaic.

Also fixes a pre-existing bug in remote-loader's constructor that discarded its `bind(this)` functions after binding them.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/39203428/badge)](https://coveralls.io/builds/39203428)

Coverage increased (+0.03%) to 95.633% when pulling **abfa5ad2249a299c280a27c58ebe45e7f664f31c on refactor/add-editor-utils** into **7c0fc34499f86cb5f8930654741c6684ba6ce2a6 on master**.


Patch:
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 97486aed..0e3ae669 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -144,6 +144,10 @@ export const DEFAULT_EDITORS = [
   DefaultEditorId.css,
 ];
 
+// main.js gets special treatment: it is required as the entry point
+// when we run fiddles or create a package.json to package fiddles.
+export const MAIN_JS = DefaultEditorId.main;
+
 export const PACKAGE_NAME = 'package.json';
 
 export const ALL_PANELS = [PanelId.docsDemo];
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index f25e9ee5..6e82b2e0 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -20,7 +20,7 @@ import { updateEditorLayout } from '../utils/editor-layout';
 import { getEditorValue } from '../utils/editor-value';
 import { getPackageJson, PackageJsonOptions } from '../utils/get-package';
 import { isEditorBackup } from '../utils/type-checks';
-import { EMPTY_EDITOR_CONTENT, SORTED_EDITORS } from './constants';
+import { compareEditors, getEmptyContent } from '../utils/editor-utils';
 import { FileManager } from './file-manager';
 import { RemoteLoader } from './remote-loader';
 import { Runner } from './runner';
@@ -83,15 +83,15 @@ export class App {
     }
 
     // If the gist content is empty or matches the empty file output, don't show it.
-    const EMPTIES = Object.values(EMPTY_EDITOR_CONTENT);
-    const shouldShowContent = (content?: string) =>
-      content?.length && !EMPTIES.includes(content);
+    const shouldShow = (id: EditorId, val?: string) => {
+      return !!val && val.length > 0 && val !== getEmptyContent(id);
+    };
 
     // Sort and display all editors that have content.
     const visibleEditors: EditorId[] = Object.entries(editorValues)
-      .filter(([_id, content]) => shouldShowContent(content))
+      .filter(([id, content]) => shouldShow(id as EditorId, content))
       .map(([id]) => id as DefaultEditorId)
-      .sort((a, b) => SORTED_EDITORS.indexOf(a) - SORTED_EDITORS.indexOf(b));
+      .sort(compareEditors);
 
     this.state.gistId = gistId || '';
     this.state.localPath = filePath;
diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx
index 13215d7e..edccf4a3 100644
--- a/src/renderer/components/commands-action-button.tsx
+++ b/src/renderer/components/commands-action-button.tsx
@@ -10,7 +10,6 @@ import {
 } from '@blueprintjs/core';
 import { observer } from 'mobx-react';
 import * as React from 'react';
-import * as path from 'path';
 
 import { when } from 'mobx';
 import {
@@ -22,7 +21,7 @@ import {
 } from '../../interfaces';
 import { IpcEvents } from '../../ipc-events';
 import { getOctokit } from '../../utils/octokit';
-import { EMPTY_EDITOR_CONTENT } from '../constants';
+import { getEmptyContent } from '../../utils/editor-utils';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
@@ -426,14 +425,13 @@ export class GistActionButton extends React.Component<
 
   private gistFilesList = (values: EditorValues) => {
     const { customMosaics } = this.props.appState;
-    const getSuffix = (name: string) => path.parse(name).ext.slice(1);
 
     const filesList = {};
 
     // Add files for default editors.
     for (const editor of DEFAULT_EDITORS) {
       filesList[editor] = {
-        content: values[editor] || EMPTY_EDITOR_CONTENT[getSuffix(editor)],
+        content: values[editor] || getEmptyContent(editor),
       };
     }
 
diff --git a/src/renderer/components/commands-editors.tsx b/src/renderer/components/commands-editors.tsx
index e9e19166..b85f4273 100644
--- a/src/renderer/components/commands-editors.tsx
+++ b/src/renderer/components/commands-editors.tsx
@@ -20,7 +20,7 @@ import {
 } from '../../interfaces';
 import { getVisibleMosaics } from '../../utils/editors-mosaic-arrangement';
 import { AppState } from '../state';
-import { TITLE_MAP } from './editors';
+import { getEditorTitle, isSupportedFile } from '../../utils/editor-utils';
 
 interface EditorDropdownState {
   value: string;
@@ -89,13 +89,14 @@ export class EditorDropdown extends React.Component<
     const allEditors = [...DEFAULT_EDITORS, ...appState.customMosaics];
     for (const id of allEditors) {
       const icon = visibleMosaics.includes(id) ? 'eye-open' : 'eye-off';
+      const title = getEditorTitle(id);
 
-      if (!Object.keys(TITLE_MAP).includes(id)) {
+      if (!DEFAULT_EDITORS.includes(id as any)) {
         result.push(
           <MenuItem
             icon={icon}
             key={id}
-            text={`Custom Editor (${id})`}
+            text={title}
             id={id}
             onClick={this.onItemClick}
             // Can't hide last editor panel.
@@ -114,7 +115,7 @@ export class EditorDropdown extends React.Component<
           <MenuItem
             icon={icon}
             key={id}
-            text={TITLE_MAP[id]}
+            text={title}
             id={id}
             onClick={this.onItemClick}
             // Can't hide last editor panel.
@@ -175,15 +176,12 @@ export class EditorDropdown extends React.Component<
   public async addCustomEditor() {
     const { appState } = this.props;
 
-    const isValidEditorName = (name: string) =>
-      /^[^\s]+\.(css|html|js)$/.test(name);
-
     const { cancelled, result } = await this.showCustomEditorDialog();
 
     if (cancelled) return;
 
     // Fail if editor name is not an accepted file type.
-    if (!result || !isValidEditorName(result)) {
+    if (!result || !isSupportedFile(result)) {
       appState.setGenericDialogOptions({
         type: GenericDialogType.warning,
         label:
diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx
index 66370fd6..61146f2c 100644
--- a/src/renderer/components/editor.tsx
+++ b/src/renderer/components/editor.tsx
@@ -7,6 +7,7 @@ import * as React from 'react';
 import { EditorId } from '../../interfaces';
 import { getContent } from '../content';
 import { AppState } from '../state';
+import { monacoLanguage } from '../../utils/editor-utils';
 
 interface EditorProps {
   appState: AppState;
@@ -32,17 +33,7 @@ export class Editor extends React.Component<EditorProps> {
   constructor(props: EditorProps) {
     super(props);
 
-    const fileExtension = props.id.split('.')[1];
-    switch (fileExtension) {
-      case 'html':
-        this.language = 'html';
-        break;
-      case 'css':
-        this.language = 'css';
-        break;
-      default:
-        this.language = 'javascript';
-    }
+    this.language = monacoLanguage(props.id);
   }
 
   public shouldComponentUpdate() {
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 6357e79e..ec640119 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -11,7 +11,6 @@ import {
 } from 'react-mosaic-component';
 
 import {
-  DefaultEditorId,
   EditorId,
   MosaicId,
   PanelId,
@@ -22,6 +21,7 @@ import { updateEditorLayout } from '../../utils/editor-layout';
 import { getFocusedEditor } from '../../utils/focused-editor';
 import { getAtPath, setAtPath } from '../../utils/js-path';
 import { toggleMonaco } from '../../utils/toggle-monaco';
+import { getEditorTitle } from '../../utils/editor-utils';
 import { isEditorId } from '../../utils/type-checks';
 import { getTemplate, getTestTemplate } from '../content';
 import { ipcRendererManager } from '../ipc';
@@ -42,15 +42,6 @@ const defaultMonacoOptions: MonacoType.editor.IEditorOptions = {
   wordWrap: 'on',
 };
 
-export const TITLE_MAP: Record<DefaultEditorId | PanelId, string> = {
-  [DefaultEditorId.main]: `Main Process (${DefaultEditorId.main})`,
-  [DefaultEditorId.renderer]: `Renderer Process (${DefaultEditorId.renderer})`,
-  [DefaultEditorId.preload]: `Preload (${DefaultEditorId.preload})`,
-  [DefaultEditorId.html]: `HTML (${DefaultEditorId.html})`,
-  [DefaultEditorId.css]: `Stylesheet (${DefaultEditorId.css})`,
-  [PanelId.docsDemo]: 'Docs & Demos',
-};
-
 interface EditorsProps {
   appState: AppState;
 }
@@ -255,9 +246,7 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
     const { appState } = this.props;
     const content =
       isEditorId(id, appState.customMosaics) && this.renderEditor(id);
-    const title = Object.keys(TITLE_MAP).includes(id)
-      ? TITLE_MAP[id]
-      : `Custom Editor (${id})`;
+    const title = getEditorTitle(id as EditorId);
 
     return (
       <MosaicWindow<EditorId>
diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts
index 73bf2692..a3e17d9b 100644
--- a/src/renderer/constants.ts
+++ b/src/renderer/constants.ts
@@ -39,11 +39,5 @@ export const DEFAULT_CLOSED_PANELS: Partial<
   'styles.css': getEditorBackup(DefaultEditorId.css),
 };
 
-export const EMPTY_EDITOR_CONTENT = {
-  html: '<!-- Empty -->',
-  js: '// Empty',
-  css: '/* Empty */',
-};
-
 export const ELECTRON_ORG = 'electron';
 export const ELECTRON_REPO = 'electron';
diff --git a/src/renderer/content.ts b/src/renderer/content.ts
index 20a5472d..625287da 100644
--- a/src/renderer/content.ts
+++ b/src/renderer/content.ts
@@ -1,6 +1,7 @@
 import { EditorId, EditorValues, VersionSource } from '../interfaces';
-import { EMPTY_EDITOR_CONTENT, USER_DATA_PATH } from './constants';
+import { USER_DATA_PATH } from './constants';
 import { getElectronVersions } from './versions';
+import { getEmptyContent } from '../utils/editor-utils';
 import { readFiddle } from '../utils/read-fiddle';
 
 import * as fs from 'fs-extra';
@@ -128,11 +129,8 @@ export async function getContent(
   version: string,
 ): Promise<string> {
   const mosaics = await getTemplate(version);
-
   if (mosaics?.[name]) return mosaics[name]!;
-
-  const extension = path.parse(name).ext.slice(1);
-  return EMPTY_EDITOR_CONTENT[extension];
+  return getEmptyContent(name);
 }
 
 /**
diff --git a/src/renderer/file-manager.ts b/src/renderer/file-manager.ts
index 55f4257d..2ac15607 100644
--- a/src/renderer/file-manager.ts
+++ b/src/renderer/file-manager.ts
@@ -1,12 +1,7 @@
 import * as fs from 'fs-extra';
 import * as path from 'path';
 
-import {
-  DefaultEditorId,
-  Files,
-  FileTransform,
-  PACKAGE_NAME,
-} from '../interfaces';
+import { Files, FileTransform, PACKAGE_NAME } from '../interfaces';
 import { IpcEvents } from '../ipc-events';
 import { DEFAULT_OPTIONS, PackageJsonOptions } from '../utils/get-package';
 import { readFiddle } from '../utils/read-fiddle';
@@ -15,6 +10,7 @@ import { AppState } from './state';
 import { getTemplateValues } from './templates';
 import { dotfilesTransform } from './transforms/dotfiles';
 import { forgeTransform } from './transforms/forge';
+import { isKnownFile } from '../utils/editor-utils';
 
 export class FileManager {
   constructor(private readonly appState: AppState) {
@@ -67,22 +63,15 @@ export class FileManager {
    */
   public async openFiddle(filePath: string) {
     const { app } = window.ElectronFiddle;
-    if (!filePath || typeof filePath !== 'string') return;
+    const { verifyCreateCustomEditor } = app.remoteLoader;
 
     console.log(`FileManager: Asked to open`, filePath);
-    const mosaics = await readFiddle(filePath);
+    if (!filePath || typeof filePath !== 'string') return;
 
     const editorValues = {};
-    for (const mosaic of Object.keys(mosaics)) {
-      if (!Object.values(DefaultEditorId).includes(mosaic as DefaultEditorId)) {
-        const verified = await app.remoteLoader.verifyCreateCustomEditor(
-          mosaic,
-        );
-        if (verified) {
-          editorValues[mosaic] = mosaics[mosaic];
-        }
-      } else {
-        editorValues[mosaic] = mosaics[mosaic];
+    for (const [name, value] of Object.entries(await readFiddle(filePath))) {
+      if (isKnownFile(name) || (await verifyCreateCustomEditor(name))) {
+        editorValues[name] = value;
       }
     }
 
diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts
index 92802007..cfcbc5cf 100644
--- a/src/renderer/remote-loader.ts
+++ b/src/renderer/remote-loader.ts
@@ -1,9 +1,5 @@
-import { Octokit } from '@octokit/rest';
 import { when } from 'mobx';
 import {
-  CustomEditorId,
-  DefaultEditorId,
-  DEFAULT_EDITORS,
   EditorValues,
   ElectronReleaseChannel,
   GenericDialogType,
@@ -14,19 +10,25 @@ import { ELECTRON_ORG, ELECTRON_REPO } from './constants';
 import { getTemplate } from './content';
 import { AppState } from './state';
 import { getReleaseChannel } from './versions';
+import { isKnownFile, isSupportedFile } from '../utils/editor-utils';
 
 export class RemoteLoader {
   constructor(private readonly appState: AppState) {
-    this.loadFiddleFromElectronExample.bind(this);
-    this.loadFiddleFromGist.bind(this);
-    this.verifyRemoteLoad.bind(this);
-    this.verifyReleaseChannelEnabled.bind(this);
-    this.fetchExampleAndLoad.bind(this);
-    this.fetchGistAndLoad.bind(this);
-    this.setElectronVersionWithRef.bind(this);
-    this.getPackageVersionFromRef.bind(this);
-    this.handleLoadingSuccess.bind(this);
-    this.handleLoadingFailed.bind(this);
+    for (const name of [
+      'fetchExampleAndLoad',
+      'fetchGistAndLoad',
+      'getPackageVersionFromRef',
+      'handleLoadingFailed',
+      'handleLoadingSuccess',
+      'loadFiddleFromElectronExample',
+      'loadFiddleFromGist',
+      'setElectronVersionWithRef',
+      'verifyCreateCustomEditor',
+      'verifyReleaseChannelEnabled',
+      'verifyRemoteLoad',
+    ]) {
+      this[name] = this[name].bind(this);
+    }
   }
 
   public async loadFiddleFromElectronExample(
@@ -84,9 +86,7 @@ export class RemoteLoader {
           continue;
         }
 
-        if (
-          Object.values(DefaultEditorId).includes(child.name as DefaultEditorId)
-        ) {
+        if (isSupportedFile(child.name)) {
           loaders.push(
             fetch(child.download_url)
               .then((r) => r.text())
@@ -105,25 +105,6 @@ export class RemoteLoader {
     }
   }
 
-  /**
-   * Get data from a gist. If it doesn't exist, return an empty string.
-   *
-   * @param {Octokit.Response<Octokit.GistsGetResponse>} gist
-   * @param {string} name
-   * @returns {string}
-   * @memberof RemoteLoader
-   */
-  public getContentOrEmpty(
-    gist: Octokit.Response<Octokit.GistsGetResponse>,
-    name: string,
-  ): string {
-    try {
-      return gist.data.files[name].content;
-    } catch (error) {
-      return '';
-    }
-  }
-
   /**
    * Load a fiddle
    *
@@ -136,23 +117,12 @@ export class RemoteLoader {
       const gist = await octo.gists.get({ gist_id: gistId });
       const values: Partial<EditorValues> = {};
 
-      // Add values for all default editors.
-      for (const editor of DEFAULT_EDITORS) {
-        values[editor] = this.getContentOrEmpty(gist, editor);
-      }
-
-      // Fetch any custom editor files that the user may have created in the gist.
-      const maybeCustomEditors = Object.keys(gist.data.files).filter(
-        (file) =>
-          !Object.values(DefaultEditorId).includes(file as DefaultEditorId),
-      );
-
-      // If it's a custom editor explicitly request permission before creation.
-      for (const mosaic of maybeCustomEditors) {
-        const verified = await this.verifyCreateCustomEditor(mosaic);
-        if (verified) {
-          values[mosaic] = this.getContentOrEmpty(gist, mosaic);
-          this.appState.customMosaics.push(mosaic as CustomEditorId);
+      for (const [id, data] of Object.entries(gist.data.files)) {
+        if (!isSupportedFile(id)) {
+          continue;
+        }
+        if (isKnownFile(id) || (await this.verifyCreateCustomEditor(id))) {
+          values[id] = data.content;
         }
       }
 
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index dc419764..d6826258 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -8,11 +8,11 @@ import {
   ALL_MOSAICS,
   BlockableAccelerator,
   DocsDemoPage,
-  DefaultEditorId,
   ElectronReleaseChannel,
   GenericDialogOptions,
   GenericDialogType,
   GistActionState,
+  MAIN_JS,
   MosaicId,
   OutputEntry,
   OutputOptions,
@@ -563,7 +563,7 @@ export class AppState {
     console.log(`State: Switching to Electron ${version}`);
 
     // Should we update the editor?
-    if (await isContentUnchanged(DefaultEditorId.main, this.version)) {
+    if (await isContentUnchanged(MAIN_JS, this.version)) {
       const editorValues = await getTemplate(version);
 
       const options: SetFiddleOptions = { templateName: version };
diff --git a/src/utils/editor-utils.ts b/src/utils/editor-utils.ts
new file mode 100644
index 00000000..025e7403
--- /dev/null
+++ b/src/utils/editor-utils.ts
@@ -0,0 +1,64 @@
+import * as path from 'path';
+import { EditorId, MAIN_JS } from '../interfaces';
+
+// The order of these fields is the order that
+// they'll be sorted in the mosaic
+const KNOWN_FILES: string[] = [
+  MAIN_JS,
+  'renderer.js',
+  'index.html',
+  'preload.js',
+  'styles.css',
+];
+
+export function isKnownFile(filename: string): boolean {
+  return KNOWN_FILES.includes(filename);
+}
+
+const TITLE_MAP: Record<EditorId, string> = Object.freeze({
+  [MAIN_JS]: `Main Process (${MAIN_JS})`,
+  'renderer.js': 'Renderer Process (renderer.js)',
+  'index.html': 'HTML (index.html)',
+  'preload.js': 'Preload (preload.js)',
+  'styles.css': 'Stylesheet (styles.css)',
+});
+
+export function getEditorTitle(id: EditorId): string {
+  return TITLE_MAP[id] || `Custom Editor (${id})`;
+}
+
+const EMPTY_EDITOR_CONTENT = {
+  css: '/* Empty */',
+  html: '<!-- Empty -->',
+  js: '// Empty',
+} as const;
+
+function getSuffix(filename: string) {
+  return path.parse(filename).ext.slice(1);
+}
+
+export function getEmptyContent(filename: string): string {
+  return EMPTY_EDITOR_CONTENT[getSuffix(filename)] || '';
+}
+
+export function isSupportedFile(filename: string): boolean {
+  return /\.(css|html|js)$/i.test(filename);
+}
+
+// first go the defaults, in the order they appear in DEFAULT_EDITORS
+// then customs, sorted lexicographically
+export function compareEditors(a: EditorId, b: EditorId) {
+  const ia = KNOWN_FILES.indexOf(a as any);
+  const ib = KNOWN_FILES.indexOf(b as any);
+  if (ia === -1 && ib === -1) return a.localeCompare(b);
+  if (ia === -1) return 1;
+  if (ib === -1) return -1;
+  return ia - ib;
+}
+
+export function monacoLanguage(filename: string) {
+  const suffix = getSuffix(filename);
+  if (suffix === 'css') return 'css';
+  if (suffix === 'html') return 'html';
+  return 'javascript';
+}
diff --git a/src/utils/get-package.ts b/src/utils/get-package.ts
index 4b5111c1..c2ded8d9 100644
--- a/src/utils/get-package.ts
+++ b/src/utils/get-package.ts
@@ -1,4 +1,4 @@
-import { DefaultEditorId, EditorValues } from '../interfaces';
+import { EditorValues, MAIN_JS } from '../interfaces';
 import { findModulesInEditors } from '../renderer/npm';
 import { AppState } from '../renderer/state';
 import { getUsername } from './get-username';
@@ -53,7 +53,7 @@ export async function getPackageJson(
       productName: name,
       description: 'My Electron application description',
       keywords: [],
-      main: `./${DefaultEditorId.main}`,
+      main: `./${MAIN_JS}`,
       version: '1.0.0',
       author: getUsername(),
       scripts: {
diff --git a/src/utils/read-fiddle.ts b/src/utils/read-fiddle.ts
index d4f64ff1..29cc9882 100644
--- a/src/utils/read-fiddle.ts
+++ b/src/utils/read-fiddle.ts
@@ -1,4 +1,5 @@
 import { DefaultEditorId, EditorValues } from '../interfaces';
+import { isKnownFile, isSupportedFile } from './editor-utils';
 
 import * as fs from 'fs-extra';
 import * as path from 'path';
@@ -22,9 +23,6 @@ export async function readFiddle(folder: string): Promise<EditorValues> {
   const hits: string[] = [];
   const misses = new Set(Object.values(DefaultEditorId));
 
-  const isValidEditorName = (name: string) =>
-    /^[^\s]+\.(css|html|js)$/i.test(name);
-
   const tryRead = (name: string) => {
     try {
       const filename = path.join(folder, name);
@@ -42,9 +40,9 @@ export async function readFiddle(folder: string): Promise<EditorValues> {
     console.warn(`readFiddle(): "${folder}" does not exist`);
   } else {
     for (const file of fs.readdirSync(folder)) {
-      if (Object.values(DefaultEditorId).includes(file as DefaultEditorId)) {
+      if (isKnownFile(file)) {
         defaultMosaics[file] = tryRead(file);
-      } else if (isValidEditorName(file)) {
+      } else if (isSupportedFile(file)) {
         customMosaics[file] = tryRead(file);
       }
     }


Test patch:
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index 0e7a4ce8..7f3892ee 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -160,10 +160,10 @@ describe('App component', () => {
       const gistId = 'gistId';
       await app.replaceFiddle(editorValues, { gistId });
       expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
-        file,
         DefaultEditorId.main,
         DefaultEditorId.renderer,
         DefaultEditorId.html,
+        file,
       ]);
     });
 
diff --git a/tests/renderer/components/__snapshots__/editors-spec.tsx.snap b/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
index 8d5831a5..0838af84 100644
--- a/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
@@ -917,7 +917,7 @@ exports[`Editors component renders 1`] = `
           <div>
             <div>
               <h5>
-                Docs & Demos
+                Custom Editor (docsDemo)
               </h5>
             </div>
             <div />
@@ -1039,14 +1039,14 @@ exports[`Editors component renders 1`] = `
             <div
               className="mosaic-window-title"
             >
-              Docs & Demos
+              Custom Editor (docsDemo)
             </div>
           </div>
           <div
             className="mosaic-window-body"
           >
             <h4>
-              Docs & Demos
+              Custom Editor (docsDemo)
             </h4>
             <span
               className="bp3-icon bp3-icon-application"
diff --git a/tests/renderer/remote-loader-spec.ts b/tests/renderer/remote-loader-spec.ts
index 5e4206d9..a9fbeee3 100644
--- a/tests/renderer/remote-loader-spec.ts
+++ b/tests/renderer/remote-loader-spec.ts
@@ -6,9 +6,8 @@ import {
 import { ipcRendererManager } from '../../src/renderer/ipc';
 import { RemoteLoader } from '../../src/renderer/remote-loader';
 import { getOctokit } from '../../src/utils/octokit';
-
-import { FetchMock } from '../utils';
 import { AppMock, StateMock } from '../mocks/mocks';
+import { FetchMock } from '../utils';
 
 jest.mock('../../src/utils/octokit');
 
@@ -130,7 +129,6 @@ describe('RemoteLoader', () => {
       const result = await instance.fetchGistAndLoad('customtestid');
 
       expect(result).toBe(true);
-      expect(store.customMosaics).toEqual([file]);
       expect(app.replaceFiddle).toBeCalledWith(
         {
           [DefaultEditorId.html]: mockGistFiles[DefaultEditorId.html].content,
@@ -165,7 +163,6 @@ describe('RemoteLoader', () => {
 
     beforeEach(() => {
       instance.setElectronVersionWithRef = jest.fn().mockReturnValueOnce(true);
-
       fetchMock = new FetchMock();
       for (const { name, download_url } of mockRepos) {
         fetchMock.add(download_url, name);
diff --git a/tests/utils/editor-utils-spec.ts b/tests/utils/editor-utils-spec.ts
new file mode 100644
index 00000000..159eaf06
--- /dev/null
+++ b/tests/utils/editor-utils-spec.ts
@@ -0,0 +1,79 @@
+import { DEFAULT_EDITORS, MAIN_JS } from '../../src/interfaces';
+import {
+  compareEditors,
+  getEditorTitle,
+  getEmptyContent,
+  isKnownFile,
+  isSupportedFile,
+} from '../../src/utils/editor-utils';
+
+describe('editor-utils', () => {
+  describe('getEditorTitle', () => {
+    it('recognizes default titles', () => {
+      expect(getEditorTitle(MAIN_JS)).toBe('Main Process (main.js)');
+    });
+    it('recognizes custom titles', () => {
+      expect(getEditorTitle('foo.js')).toBe('Custom Editor (foo.js)');
+    });
+  });
+
+  describe('getEmptyContent', () => {
+    it('returns comments for known types', () => {
+      expect(getEmptyContent('main.js')).toBe('// Empty');
+    });
+    it('returns an empty string for unknown types', () => {
+      expect(getEmptyContent('main.foo')).toBe('');
+    });
+  });
+
+  describe('isKnownFile', () => {
+    it('marks default editors as known files', () => {
+      for (const id of DEFAULT_EDITORS) {
+        expect(isKnownFile(id)).toBe(true);
+      }
+    });
+  });
+
+  describe('isSupportedFile', () => {
+    it('supports all default editor types', () => {
+      for (const id of DEFAULT_EDITORS) {
+        expect(isSupportedFile(id)).toBe(true);
+      }
+    });
+  });
+
+  describe('compareEditors', () => {
+    it('sorts known files in a consistent order', () => {
+      const ids = [...DEFAULT_EDITORS];
+      const sorted1 = [...ids].sort(compareEditors);
+      ids.push(ids.shift()!);
+      ids.push(ids.shift()!);
+      const sorted2 = [...ids].sort(compareEditors);
+      expect(sorted1).toStrictEqual(sorted2);
+    });
+
+    it('sorts known editors before supported ones', () => {
+      const filename = 'hello.js';
+
+      let ids = [MAIN_JS, filename];
+      ids.sort(compareEditors);
+      expect(ids).toStrictEqual([MAIN_JS, filename]);
+
+      ids = [filename, MAIN_JS];
+      ids.sort(compareEditors);
+      expect(ids).toStrictEqual([MAIN_JS, filename]);
+    });
+
+    it('sorts supported files lexicographically', () => {
+      const expected = ['a.js', 'b.js'];
+
+      const ids = [...expected];
+      ids.sort(compareEditors);
+      expect(ids).toStrictEqual(expected);
+
+      ids.reverse();
+      ids.sort(compareEditors);
+      expect(ids).toStrictEqual(expected);
+    });
+  });
+});
diff --git a/tests/utils/read-fiddle-spec.ts b/tests/utils/read-fiddle-spec.ts
index 0603a16e..cc82f1df 100644
--- a/tests/utils/read-fiddle-spec.ts
+++ b/tests/utils/read-fiddle-spec.ts
@@ -2,15 +2,26 @@ import * as path from 'path';
 import * as fs from 'fs-extra';
 
 import { readFiddle } from '../../src/utils/read-fiddle';
+import { isSupportedFile } from '../../src/utils/editor-utils';
 import { DefaultEditorId } from '../../src/interfaces';
 
 const defaultFiles = ['LICENSE.txt', DefaultEditorId.main];
 
 describe('read-fiddle', () => {
+  const folder = '/some/place';
+
   beforeEach(async () => {
     (fs.existsSync as jest.Mock).mockImplementationOnce(() => true);
   });
 
+  function setupFSMocks(editorValues: Record<string, string>) {
+    (fs.existsSync as jest.Mock).mockReturnValue(true);
+    (fs.readdirSync as jest.Mock).mockReturnValue(Object.keys(editorValues));
+    (fs.readFileSync as jest.Mock).mockImplementation((filename) => {
+      return editorValues[path.basename(filename)];
+    });
+  }
+
   it('reads known content', async () => {
     (fs.readdirSync as jest.Mock).mockImplementationOnce(() => defaultFiles);
     (fs.readFileSync as jest.Mock).mockImplementation((filename) =>
@@ -54,6 +65,26 @@ describe('read-fiddle', () => {
     (console.warn as jest.Mock).mockClear();
   });
 
+  it('skips unsupported files', async () => {
+    const content = 'hello';
+    const mockValues = {
+      [DefaultEditorId.css]: content,
+      [DefaultEditorId.html]: content,
+      [DefaultEditorId.main]: content,
+      [DefaultEditorId.preload]: content,
+      [DefaultEditorId.renderer]: content,
+      'file.js': content,
+      'file.php': content,
+    };
+    setupFSMocks(mockValues);
+    const expected = Object.fromEntries(
+      Object.entries(mockValues).filter(([id, _]) => isSupportedFile(id)),
+    );
+
+    const fiddle = await readFiddle(folder);
+    expect(fiddle).toStrictEqual(expected);
+  });
+
   it('handles read errors gracefully', async () => {
     (fs.readdirSync as jest.Mock).mockImplementationOnce(() => defaultFiles);
     (fs.readFileSync as jest.Mock).mockImplementation(() => {