Repository: electron/fiddle
Pull request #683 (2021-05-03 20:08:18)

refactor: move editor & mosaic logic into EditorMosaic class
Part 5 of breaking https://github.com/electron/fiddle/pull/664 into smaller, human-reviewable pieces.

Move mosaic logic from `AppState` and `App` into `EditorMosaic`. Also move `getEditorValue()`, `getEditorBackup()`, `getEditorViewState()`, `getEditorModel()`, and `updateEditorLayout()` from `utils/` into `EditorMosaic`.

No significant logic changes; this is just consolidation. E.g. the `EditorBackup` type is still public for testing even though the only production customer is EditorMosaic. The goal here is to move most of the related mosaic code into one place so that the responsibilities / dependencies are clearer before we start changing implementation.

![](https://imgflip.com/s/meme/Put-It-Somewhere-Else-Patrick.jpg)



Patch:
diff --git a/.github/semantic.yml b/.github/semantic.yml
new file mode 100644
index 00000000..4168a3cd
--- /dev/null
+++ b/.github/semantic.yml
@@ -0,0 +1,2 @@
+# Always validate the PR title, and ignore the commits
+titleOnly: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2423ea9f..f17d109b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -27,8 +27,8 @@ Here are some things to keep in mind as you file pull requests to fix bugs, add
 
 * If you're unfamiliar with forking, branching, and pull requests, please see [GitHub's extensive
   documentation](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests).
-* Travis CI and AppVeyor are used to make sure that any new or changed code meets the project's
-  style guidelines, and that the project's testsuite passes for each new commit.
+* GitHub Actions is used to make sure that any new or changed code meets the project's
+  style guidelines, and that the project's test suite passes for each new commit.
 * Unless it's impractical, please write tests for your changes. This will help us so that we can
   spot regressions much easier.
 * When creating commit messages and pull request titles, please adhere to the [conventional
@@ -57,7 +57,7 @@ cd fiddle
 yarn
 ```
 
-4. Start Fiddle and Explore!
+4. Start Fiddle and explore!
 
 ```sh
 yarn start
@@ -76,12 +76,30 @@ environment variable `FIDDLE_VERBOSE_TESTS` when running.
 
 ## Release Process
 
-First, bump the version number and create a new `git` tag:
+> **Note:** Releasing is only available to contributors who have write
+> access to the `electron/fiddle` repository.
+
+First, create a new branch for your release.
+
+Then, run the [`yarn version`](https://classic.yarnpkg.com/en/docs/cli/version/)
+command, which will guide you through an interactive prompt to update the package
+version number.
 
 ```sh
 yarn version
+info Current version: 1.0.1
+question New version: 1.0.2
+info New version: 1.0.2
+✨  Done in 9.42s.
 ```
 
-Then, push that tag to the GitHub repository. CI will automatically draft a new
-release. Check that all the expected files are present, that installers work,
-and then publish the release.
+After running the command, you should have a commit updating the `version` field in
+the `package.json` file, as well as a git tag corresponding with the new version
+number.
+
+Then, you'll want to commit the version bump change onto the main branch. Push
+your commit upstream and open a new pull request.
+
+Finally, push your tag to the GitHub repository with `git push origin <tag_name>`.
+CI will automatically draft a new release. Check that all the expected files are present,
+that installers work, and then publish the release.
diff --git a/README.md b/README.md
index 4b7003d6..7c87084a 100644
--- a/README.md
+++ b/README.md
@@ -10,98 +10,12 @@ save your Fiddle either as a GitHub Gist or to a local folder. Once published
 on GitHub, anyone can quickly try your Fiddle out by just entering it in the
 address bar.
 
-# Download
-
-<table>
-<tbody>
-</tbody>
-  <tr>
-    <td>
-      <img src="./.github/images/windows.png" width="24"><br />
-      Windows
-    </td>
-    <td>
-      <span>32-bit</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-win32-ia32-setup.exe">
-        💿 Installer
-      </a>
-      <br />
-      <span>64-bit</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-win32-x64-setup.exe">
-        💿 Installer
-      </a>
-      <br />
-      <span>ARM64</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-win32-arm64-setup.exe">
-        💿 Installer
-      </a>
-      <span>
-        ❓ Don't know what kind of chip you have? Hit start, enter "processor" for info.
-      </span>
-    </td>
-  </tr>
-  <tr>
-    <td>
-      <img src="./.github/images/macos.png" width="24"><br />
-      macOS
-    </td>
-    <td>
-      <span>Intel Processor</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/Electron.Fiddle-darwin-x64-0.20.0.zip">
-        📦 Standalone Zip
-      </a><br />
-      <span>Apple Silicon Processor</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/Electron.Fiddle-darwin-arm64-0.20.0.zip">
-        📦 Standalone Zip
-      </a><br />
-      <span>
-        ❓ Don't know what kind of chip you have? Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
-      </span>
-    </td>
-  </tr>
-  <tr>
-    <td>
-      <img src="./.github/images/linux.png" width="24"><br />
-      Linux
-    </td>
-    <td>
-      <span>32-bit</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-1.i386.rpm">
-        💿 rpm
-      </a> |
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle_0.20.0_i386.deb">
-        💿 deb
-      </a><br />
-      <span>64-bit</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-1.x86_64.rpm">
-        💿 rpm
-      </a> |
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle_0.20.0_amd64.deb">
-        💿 deb
-      </a><br />
-      <span>ARM64</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-1.arm64.rpm">
-        💿 rpm
-      </a> |
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle_0.20.0_arm64.deb">
-        💿 deb
-      </a><br />
-      <span>ARMv7 (armhf)</span>
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle-0.20.0-1.arm64.rpm">
-        💿 rpm
-      </a> |
-      <a href="https://github.com/electron/fiddle/releases/download/v0.20.0/electron-fiddle_0.20.0_armhf.deb">
-        💿 deb
-      </a>
-    </td>
-  </tr>
-</table>
-
-<hr />
+**[Download Fiddle now!](https://www.electronjs.org/fiddle)**
+
 
 <img src="https://user-images.githubusercontent.com/1426799/52155868-d3357c80-2639-11e9-9496-fa97b1dc7897.jpg" width="880px" alt="Electron Fiddle screenshot">
 
-# Features
+## Features
 
 ### Explore Electron
 
diff --git a/package.json b/package.json
index f656f857..db7b61de 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "electron-fiddle",
   "productName": "Electron Fiddle",
-  "version": "0.22.0",
+  "version": "0.23.1",
   "description": "The easiest way to get started with Electron",
   "repository": "https://github.com/electron/fiddle",
   "main": "./dist/src/main/main",
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 95b400e1..c965551e 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -6,22 +6,13 @@ import * as MonacoType from 'monaco-editor';
 
 import { ipcRenderer } from 'electron';
 import {
-  DefaultEditorId,
-  EditorId,
   EditorValues,
   GenericDialogType,
   PACKAGE_NAME,
   SetFiddleOptions,
 } from '../interfaces';
 import { WEBCONTENTS_READY_FOR_IPC_SIGNAL } from '../ipc-events';
-import { updateEditorLayout } from '../utils/editor-layout';
-import { getEditorValue } from '../utils/editor-value';
 import { getPackageJson, PackageJsonOptions } from '../utils/get-package';
-import {
-  compareEditors,
-  getEmptyContent,
-  isKnownFile,
-} from '../utils/editor-utils';
 import { FileManager } from './file-manager';
 import { RemoteLoader } from './remote-loader';
 import { Runner } from './runner';
@@ -48,7 +39,6 @@ export class App {
 
   constructor() {
     this.getEditorValues = this.getEditorValues.bind(this);
-    this.setEditorValues = this.setEditorValues.bind(this);
 
     this.taskRunner = new TaskRunner(this);
   }
@@ -72,69 +62,16 @@ export class App {
       }
     }
 
-    // Remove all previously created custom editors.
-    this.state.customMosaics = Object.keys(editorValues).filter(
-      (filename: string) => !isKnownFile(filename),
-    ) as EditorId[];
-
-    // If the gist content is empty or matches the empty file output, don't show it.
-    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]) => shouldShow(id as EditorId, content as string))
-      .map(([id]) => id as DefaultEditorId)
-      .sort(compareEditors);
+    await this.state.editorMosaic.set(editorValues);
 
     this.state.gistId = gistId || '';
     this.state.localPath = filePath;
     this.state.templateName = templateName;
-
-    // Once loaded, we have a "saved" state.
-    await this.state.setVisibleMosaics(visibleEditors);
-    await this.setEditorValues(editorValues);
     this.state.isUnsaved = false;
 
     return true;
   }
 
-  /**
-   * Sets the contents of all editor panes.
-   *
-   * @param {EditorValues} values
-   */
-  public async setEditorValues(values: EditorValues): Promise<void> {
-    const { ElectronFiddle: fiddle } = window;
-
-    if (!fiddle?.app) {
-      throw new Error('Fiddle not ready');
-    }
-
-    // Set content for mosaics.
-    const { closedPanels, editorMosaic } = this.state;
-    for (const [name, content] of Object.entries(values)) {
-      const backup = closedPanels[name];
-      if (backup) {
-        // The editor does not exist, attempt to set it on the backup.
-        // If there's a model, we'll do it on the model. Else, we'll
-        // set the value.
-        if (backup.model) {
-          backup.model.setValue(values[name]!);
-        } else {
-          backup.value = values[name]!;
-        }
-      } else {
-        const editor = editorMosaic.editors.get(name as EditorId);
-        // The editor exists, set the value directly
-        if (editor && editor.getValue() !== content) {
-          editor.setValue(content as string);
-        }
-      }
-    }
-  }
-
   /**
    * Retrieves the contents of all editor panes.
    *
@@ -143,16 +80,7 @@ export class App {
   public async getEditorValues(
     options?: PackageJsonOptions,
   ): Promise<EditorValues> {
-    const { ElectronFiddle: fiddle } = window;
-
-    if (!fiddle?.app) {
-      throw new Error('Fiddle not ready');
-    }
-
-    const values: EditorValues = {};
-    for (const name of this.state.editorMosaic.editors.keys()) {
-      values[name] = getEditorValue(name);
-    }
+    const values = this.state.editorMosaic.values();
 
     if (options && options.include !== false) {
       values[PACKAGE_NAME] = await getPackageJson(this.state, values, options);
@@ -269,7 +197,7 @@ export class App {
    * is resized. This method sets up the listener.
    */
   public setupResizeListener(): void {
-    window.addEventListener('resize', updateEditorLayout);
+    window.addEventListener('resize', this.state.editorMosaic.layout);
   }
 
   /**
diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx
index 17edd28f..941ca06c 100644
--- a/src/renderer/components/commands-action-button.tsx
+++ b/src/renderer/components/commands-action-button.tsx
@@ -424,7 +424,7 @@ export class GistActionButton extends React.Component<
   };
 
   private gistFilesList = (values: EditorValues) => {
-    const { customMosaics } = this.props.appState;
+    const { customMosaics } = this.props.appState.editorMosaic;
 
     const filesList = {};
 
diff --git a/src/renderer/components/commands-editors.tsx b/src/renderer/components/commands-editors.tsx
index 3c80be0d..741f9aba 100644
--- a/src/renderer/components/commands-editors.tsx
+++ b/src/renderer/components/commands-editors.tsx
@@ -65,9 +65,14 @@ export class EditorDropdown extends React.Component<
   public renderMenuItems() {
     const { appState } = this.props;
     const result: Array<JSX.Element> = [];
-    const visibleMosaics = getVisibleMosaics(appState.mosaicArrangement);
+    const visibleMosaics = getVisibleMosaics(
+      appState.editorMosaic.mosaicArrangement,
+    );
 
-    const allEditors = [...DEFAULT_EDITORS, ...appState.customMosaics];
+    const allEditors = [
+      ...DEFAULT_EDITORS,
+      ...appState.editorMosaic.customMosaics,
+    ];
     for (const id of allEditors) {
       const icon = visibleMosaics.includes(id) ? 'eye-open' : 'eye-off';
       const title = getEditorTitle(id);
@@ -81,7 +86,7 @@ export class EditorDropdown extends React.Component<
             id={id}
             onClick={this.onItemClick}
             // Can't hide last editor panel.
-            disabled={appState.mosaicArrangement === id}
+            disabled={appState.editorMosaic.mosaicArrangement === id}
           >
             <MenuItem
               icon={'cross'}
@@ -100,7 +105,7 @@ export class EditorDropdown extends React.Component<
             id={id}
             onClick={this.onItemClick}
             // Can't hide last editor panel.
-            disabled={appState.mosaicArrangement === id}
+            disabled={appState.editorMosaic.mosaicArrangement === id}
           />,
         );
       }
@@ -125,7 +130,7 @@ export class EditorDropdown extends React.Component<
           icon="grid-view"
           key="reset-layout"
           text="Reset Layout"
-          onClick={appState.resetEditorLayout}
+          onClick={appState.editorMosaic.resetEditorLayout}
         />
       </React.Fragment>,
     );
@@ -176,7 +181,7 @@ export class EditorDropdown extends React.Component<
 
       // Also fail if the user tries to create two identical editors.
       if (
-        appState.customMosaics.includes(name) ||
+        appState.editorMosaic.customMosaics.includes(name) ||
         Object.values(DefaultEditorId).includes(name as DefaultEditorId)
       ) {
         appState.setGenericDialogOptions({
@@ -187,8 +192,8 @@ export class EditorDropdown extends React.Component<
 
         appState.toggleGenericDialog();
       } else {
-        appState.customMosaics.push(name);
-        appState.showMosaic(name);
+        appState.editorMosaic.customMosaics.push(name);
+        appState.editorMosaic.showMosaic(name);
       }
     }
   }
@@ -198,20 +203,23 @@ export class EditorDropdown extends React.Component<
     const { appState } = this.props;
 
     console.log(`EditorDropdown: Removing custom editor ${id}`);
-    appState.removeCustomMosaic(id as EditorId);
+    appState.editorMosaic.removeCustomMosaic(id as EditorId);
   }
 
   public onItemClick(event: React.MouseEvent) {
     const { id } = event.currentTarget;
     const { appState } = this.props;
-    const visibleMosaics = getVisibleMosaics(appState.mosaicArrangement);
+    const { editorMosaic } = appState;
+    const visibleMosaics = getVisibleMosaics(
+      appState.editorMosaic.mosaicArrangement,
+    );
 
     if (visibleMosaics.includes(id as EditorId)) {
       console.log(`EditorDropdown: Closing ${id}`);
-      appState.hideAndBackupMosaic(id as EditorId);
+      editorMosaic.hideAndBackupMosaic(id as EditorId);
     } else {
       console.log(`EditorDropdown: Opening ${id}`);
-      appState.showMosaic(id as EditorId);
+      editorMosaic.showMosaic(id as EditorId);
     }
   }
 }
diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx
index a1dafd9b..1b254ba2 100644
--- a/src/renderer/components/editor.tsx
+++ b/src/renderer/components/editor.tsx
@@ -136,7 +136,7 @@ export class Editor extends React.Component<EditorProps> {
     const { appState, id } = this.props;
     const { version } = appState;
 
-    const backup = appState.getAndRemoveEditorValueBackup(id);
+    const backup = appState.editorMosaic.getAndRemoveEditorValueBackup(id);
 
     if (backup) {
       console.log(`Editor: Backup found, restoring state`);
diff --git a/src/renderer/components/editors-non-ideal-state.tsx b/src/renderer/components/editors-non-ideal-state.tsx
index 40d6cb92..f1f199c2 100644
--- a/src/renderer/components/editors-non-ideal-state.tsx
+++ b/src/renderer/components/editors-non-ideal-state.tsx
@@ -15,7 +15,7 @@ export function renderNonIdealState(appState: AppState) {
   const resolveButton = (
     <Button
       text="Open all editors"
-      onClick={() => appState.setVisibleMosaics(allEditors)}
+      onClick={() => appState.editorMosaic.setVisibleMosaics(allEditors)}
     />
   );
 
diff --git a/src/renderer/components/editors-toolbar-button.tsx b/src/renderer/components/editors-toolbar-button.tsx
index 2461a972..115e4b8b 100644
--- a/src/renderer/components/editors-toolbar-button.tsx
+++ b/src/renderer/components/editors-toolbar-button.tsx
@@ -58,7 +58,7 @@ export class RemoveButton extends ToolbarButton {
    */
   public createButton(_mosaicActions: MosaicRootActions<any>) {
     const onClick = () =>
-      this.props.appState.hideAndBackupMosaic(this.props.id);
+      this.props.appState.editorMosaic.hideAndBackupMosaic(this.props.id);
 
     return <Button icon="cross" className="bp3-small" onClick={onClick} />;
   }
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 9a7ddb31..c089f75b 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -1,4 +1,3 @@
-import { reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as MonacoType from 'monaco-editor';
 import * as React from 'react';
@@ -12,7 +11,6 @@ import {
 
 import { EditorId, SetFiddleOptions } from '../../interfaces';
 import { IpcEvents } from '../../ipc-events';
-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';
@@ -45,13 +43,6 @@ interface EditorsState {
 
 @observer
 export class Editors extends React.Component<EditorsProps, EditorsState> {
-  // A reaction: Each time mosaicArrangement is changed, we'll update
-  // the editor layout. That method is itself debounced.
-  public disposeLayoutAutorun = reaction(
-    () => this.props.appState.mosaicArrangement,
-    () => updateEditorLayout(),
-  );
-
   constructor(props: EditorsProps) {
     super(props);
 
@@ -116,7 +107,6 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
   }
 
   public componentWillUnmount() {
-    this.disposeLayoutAutorun();
     this.stopListening();
   }
 
@@ -188,8 +178,8 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
 
     // only show toolbar controls if we have more than 1 visible editor
     // Mosaic arrangement is type string if 1 editor, object otherwise
-    const toolbarControlsMaybe = typeof appState.mosaicArrangement !==
-      'string' && (
+    const toolbarControlsMaybe = typeof appState.editorMosaic
+      .mosaicArrangement !== 'string' && (
       <>
         <MaximizeButton id={id} appState={appState} />
         <RemoveButton id={id} appState={appState} />
@@ -267,7 +257,7 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
       <Mosaic<EditorId>
         className={`focused__${this.state.focused}`}
         onChange={this.onChange}
-        value={appState.mosaicArrangement}
+        value={appState.editorMosaic.mosaicArrangement}
         zeroStateView={renderNonIdealState(appState)}
         renderTile={this.renderTile}
       />
@@ -280,7 +270,7 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
    * @param {(MosaicNode<EditorId> | null)} currentNode
    */
   public onChange(currentNode: MosaicNode<EditorId> | null) {
-    this.props.appState.mosaicArrangement = currentNode;
+    this.props.appState.editorMosaic.mosaicArrangement = currentNode;
   }
 
   /**
diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts
index f668a175..607d229b 100644
--- a/src/renderer/constants.ts
+++ b/src/renderer/constants.ts
@@ -2,7 +2,6 @@ import * as path from 'path';
 import { MosaicNode } from 'react-mosaic-component';
 
 import { DefaultEditorId, EditorId } from '../interfaces';
-import { EditorBackup, getEditorBackup } from '../utils/editor-backup';
 
 export const USER_DATA_PATH = window.ElectronFiddle.appPaths.userData;
 export const CONFIG_PATH = path.join(
@@ -32,11 +31,5 @@ export const DEFAULT_MOSAIC_ARRANGEMENT: MosaicNode<EditorId> = {
   },
 };
 
-export const DEFAULT_CLOSED_PANELS: Partial<
-  Record<EditorId, EditorBackup | true>
-> = {
-  'styles.css': getEditorBackup(DefaultEditorId.css),
-};
-
 export const ELECTRON_ORG = 'electron';
 export const ELECTRON_REPO = 'electron';
diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts
index 30efa2f9..88aa2892 100644
--- a/src/renderer/editor-mosaic.ts
+++ b/src/renderer/editor-mosaic.ts
@@ -1,10 +1,272 @@
 import * as MonacoType from 'monaco-editor';
-import { observable } from 'mobx';
+import { MosaicNode } from 'react-mosaic-component';
+import { action, observable, reaction } from 'mobx';
 
-import { EditorId } from '../interfaces';
+import {
+  createMosaicArrangement,
+  getVisibleMosaics,
+} from '../utils/editors-mosaic-arrangement';
+import { DEFAULT_MOSAIC_ARRANGEMENT } from './constants';
+import {
+  DEFAULT_EDITORS,
+  DefaultEditorId,
+  EditorId,
+  EditorValues,
+} from '../interfaces';
+import { waitForEditorsToMount } from '../utils/editor-mounted';
+import {
+  compareEditors,
+  getEmptyContent,
+  isKnownFile,
+} from '../utils/editor-utils';
 
 export type Editor = MonacoType.editor.IStandaloneCodeEditor;
 
+export interface EditorBackup {
+  value?: string;
+  model?: MonacoType.editor.ITextModel | null;
+  viewState?: MonacoType.editor.ICodeEditorViewState | null;
+}
+
 export class EditorMosaic {
   @observable public readonly editors: Map<EditorId, Editor> = new Map();
+  @observable public customMosaics: EditorId[] = [];
+  @observable
+  public mosaicArrangement: MosaicNode<EditorId> | null = DEFAULT_MOSAIC_ARRANGEMENT;
+  @observable public closedPanels: Record<EditorId, EditorBackup> = {};
+
+  constructor() {
+    for (const name of [
+      'getAndRemoveEditorValueBackup',
+      'hideAndBackupMosaic',
+      'layout',
+      'removeCustomMosaic',
+      'resetEditorLayout',
+      'set',
+      'setVisibleMosaics',
+      'showMosaic',
+      'values',
+    ]) {
+      this[name] = this[name].bind(this);
+    }
+    // A reaction: Each time mosaicArrangement is changed, we'll update
+    // the editor layout. That method is itself debounced.
+    reaction(
+      () => this.mosaicArrangement,
+      () => this.layout(),
+    );
+  }
+
+  /**
+   * Sets the editor value for a given editor. Deletes the value after
+   * accessing it.
+   *
+   * @param {EditorId} id
+   */
+  @action public getAndRemoveEditorValueBackup(
+    id: EditorId,
+  ): EditorBackup | null {
+    const value = this.closedPanels[id];
+    delete this.closedPanels[id];
+    return value;
+  }
+
+  @action public async setVisibleMosaics(visible: Array<EditorId>) {
+    const { editors, mosaicArrangement } = this;
+    const currentlyVisible = getVisibleMosaics(mosaicArrangement);
+
+    for (const id of DEFAULT_EDITORS) {
+      if (!visible.includes(id) && currentlyVisible.includes(id)) {
+        this.closedPanels[id] = this.getEditorBackup(id);
+        // if we have backup, remove active editor
+        editors.delete(id);
+      }
+    }
+
+    const updatedArrangement = createMosaicArrangement(visible);
+    console.log(
+      `State: Setting visible mosaic panels`,
+      visible,
+      updatedArrangement,
+    );
+
+    this.mosaicArrangement = updatedArrangement;
+
+    // after the mosaicArrangement loads, we want to wait for the Mosaic editors to
+    // mount to ensure that we can load content into the editors as soon as they're
+    // declared visible.
+
+    await waitForEditorsToMount(visible);
+  }
+
+  /**
+   * Hides the panel for a given EditorId.
+   *
+   * @param {EditorId} id
+   */
+  @action public hideAndBackupMosaic(id: EditorId) {
+    const currentlyVisible = getVisibleMosaics(this.mosaicArrangement);
+    this.setVisibleMosaics(currentlyVisible.filter((v) => v !== id));
+  }
+
+  /**
+   * Removes the panel for a given custom EditorId.
+   *
+   * @param {EditorId} id
+   */
+  @action public removeCustomMosaic(id: EditorId) {
+    this.hideAndBackupMosaic(id);
+    this.editors.delete(id);
+    this.customMosaics = this.customMosaics.filter((mosaic) => mosaic !== id);
+  }
+
+  /**
+   * Shows the editor value for a given editor.
+   *
+   * @param {EditorId} id
+   */
+  @action public showMosaic(id: EditorId) {
+    const currentlyVisible = getVisibleMosaics(this.mosaicArrangement);
+    this.setVisibleMosaics([...currentlyVisible, id]);
+  }
+
+  /**
+   * Resets editor view to default layout.
+   *
+   *
+   */
+  @action public resetEditorLayout() {
+    this.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
+  }
+
+  @action public async set(editorValues: EditorValues) {
+    // Remove all previously created custom editors.
+    this.customMosaics = Object.keys(editorValues).filter(
+      (filename: string) => !isKnownFile(filename),
+    ) as EditorId[];
+
+    // If the gist content is empty or matches the empty file output, don't show it.
+    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]) => shouldShow(id as EditorId, content as string))
+      .map(([id]) => id as DefaultEditorId)
+      .sort(compareEditors);
+
+    // Once loaded, we have a "saved" state.
+    await this.setVisibleMosaics(visibleEditors);
+
+    // Set content for mosaics.
+    for (const [name, value] of Object.entries(editorValues)) {
+      const editor = this.editors.get(name as EditorId);
+      if (editor) {
+        // The editor exists, set the value directly
+        if (editor.getValue() !== value) {
+          editor.setValue(value as string);
+        }
+      } else {
+        // The editor does not exist, attempt to set it on the backup.
+        // If there's a model, we'll do it on the model. Else, we'll
+        // set the value.
+        let backup = this.closedPanels[name];
+        if (!backup) {
+          backup = { value };
+          this.closedPanels[name] = backup;
+        } else if (backup.model) {
+          backup.model.setValue(value);
+        } else {
+          backup.value = value;
+        }
+      }
+    }
+  }
+
+  /**
+   * Retrieves the contents of all editor panes.
+   *
+   * @returns {EditorValues}
+   */
+  public values(): EditorValues {
+    const values: EditorValues = {};
+
+    for (const name of Object.keys(this.closedPanels)) {
+      values[name] = this.getEditorValue(name as EditorId);
+    }
+
+    for (const name of this.editors.keys()) {
+      values[name] = this.getEditorValue(name);
+    }
+
+    return values;
+  }
+
+  /**
+   * Return the value for a given editor
+   *
+   * @param {EditorId} id
+   * @returns {string}
+   */
+  public getEditorValue(id: EditorId): string {
+    const editor = this.editors.get(id);
+    if (editor) return editor.getValue();
+
+    const backup = this.closedPanels[id];
+    if (backup?.value) return backup.value;
+
+    return '';
+  }
+
+  /**
+   * Returns a backup for a given editor
+   *
+   * @param {EditorId} id
+   * @returns {EditorBackup}
+   */
+  public getEditorBackup(id: EditorId): EditorBackup {
+    return {
+      value: this.getEditorValue(id),
+      model: this.getEditorModel(id),
+      viewState: this.getEditorViewState(id),
+    };
+  }
+
+  /**
+   * Returns the view state for a given editor.
+   *
+   * @export
+   * @param {EditorId} id
+   * @returns {(editor.ICodeEditorViewState | null)}
+   */
+  public getEditorViewState(
+    id: EditorId,
+  ): MonacoType.editor.ICodeEditorViewState | null {
+    return this.editors.get(id)?.saveViewState() || null;
+  }
+
+  /**
+   * Return the model for a given editor
+   *
+   * @param {EditorId} id
+   * @returns {editor.ITextModel | null}
+   */
+  public getEditorModel(id: EditorId): MonacoType.editor.ITextModel | null {
+    return this.editors.get(id)?.getModel() || null;
+  }
+
+  private layoutDebounce: any;
+
+  public layout() {
+    const DEBOUNCE_MSEC = 50;
+    if (!this.layoutDebounce) {
+      this.layoutDebounce = setTimeout(() => {
+        for (const editor of this.editors.values()) {
+          editor?.layout?.();
+        }
+        this.layoutDebounce = null;
+      }, DEBOUNCE_MSEC);
+    }
+  }
 }
diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts
index ac6b7018..af99a284 100644
--- a/src/renderer/runner.ts
+++ b/src/renderer/runner.ts
@@ -327,6 +327,7 @@ export class Runner {
     const {
       currentElectronVersion,
       isEnablingElectronLogging,
+      flushOutput,
       pushOutput,
       executionFlags,
       environmentVariables,
@@ -368,6 +369,8 @@ export class Runner {
         pushOutput(data, { bypassBuffer: false }),
       );
       this.child.on('close', async (code) => {
+        flushOutput();
+
         this.appState.isRunning = false;
         this.child = null;
 
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index 2338e1e5..095ae20a 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -2,12 +2,9 @@ import * as fs from 'fs-extra';
 import * as MonacoType from 'monaco-editor';
 import semver from 'semver';
 import { action, autorun, computed, observable, when } from 'mobx';
-import { MosaicNode } from 'react-mosaic-component';
 
 import {
   BlockableAccelerator,
-  DEFAULT_EDITORS,
-  EditorId,
   ElectronReleaseChannel,
   GenericDialogOptions,
   GenericDialogType,
@@ -22,17 +19,11 @@ import {
   VersionState,
 } from '../interfaces';
 import { IpcEvents } from '../ipc-events';
-import { EditorBackup, getEditorBackup } from '../utils/editor-backup';
-import {
-  createMosaicArrangement,
-  getVisibleMosaics,
-} from '../utils/editors-mosaic-arrangement';
 import { getName } from '../utils/get-name';
 import { normalizeVersion } from '../utils/normalize-version';
 import { removeBinary, setupBinary } from './binary';
 import { Bisector } from './bisect';
 import { EditorMosaic } from './editor-mosaic';
-import { DEFAULT_CLOSED_PANELS, DEFAULT_MOSAIC_ARRANGEMENT } from './constants';
 import { getTemplate, isContentUnchanged } from './content';
 import {
   getLocalTypePathForVersion,
@@ -131,11 +122,8 @@ export class AppState {
     placeholder: '',
   };
   @observable public readonly editorMosaic = new EditorMosaic();
-  @observable public customMosaics: EditorId[] = [];
   @observable public genericDialogLastResult: boolean | null = null;
   @observable public genericDialogLastInput: string | null = null;
-  @observable
-  public mosaicArrangement: MosaicNode<EditorId> | null = DEFAULT_MOSAIC_ARRANGEMENT;
   @observable public templateName: string | undefined;
   @observable public localTypeWatcher: fs.FSWatcher | undefined;
   @observable public Bisector: Bisector | undefined;
@@ -160,11 +148,6 @@ export class AppState {
   @observable public isTourShowing = !localStorage.getItem('hasShownTour');
 
   // -- Editor Values stored when we close the editor ------------------
-  @observable public closedPanels: Record<
-    EditorId,
-    EditorBackup
-  > = DEFAULT_CLOSED_PANELS;
-
   private outputBuffer = '';
   private name: string;
   private readonly defaultVersion: string;
@@ -175,6 +158,7 @@ export class AppState {
     this.downloadVersion = this.downloadVersion.bind(this);
     this.pushError = this.pushError.bind(this);
     this.pushOutput = this.pushOutput.bind(this);
+    this.flushOutput = this.flushOutput.bind(this);
     this.getVersion = this.getVersion.bind(this);
     this.hasVersion = this.hasVersion.bind(this);
     this.removeVersion = this.removeVersion.bind(this);
@@ -188,7 +172,6 @@ export class AppState {
     this.toggleSettings = this.toggleSettings.bind(this);
     this.toggleBisectDialog = this.toggleBisectDialog.bind(this);
     this.updateElectronVersions = this.updateElectronVersions.bind(this);
-    this.resetEditorLayout = this.resetEditorLayout.bind(this);
     this.setIsQuitting = this.setIsQuitting.bind(this);
     this.addAcceleratorToBlock = this.addAcceleratorToBlock.bind(this);
     this.removeAcceleratorToBlock = this.removeAcceleratorToBlock.bind(this);
@@ -615,6 +598,16 @@ export class AppState {
     this.gitHubName = null;
   }
 
+  /**
+   * Ensure that any buffered console output is
+   * printed before a running Fiddle is stopped.
+   *
+   * @returns {void}
+   */
+  @action public flushOutput(): void {
+    this.pushOutput('\r\n', { bypassBuffer: false });
+  }
+
   /**
    * Push output to the application's state. Accepts a buffer or a string as input,
    * attaches a timestamp, and pushes into the store.
@@ -628,12 +621,11 @@ export class AppState {
     let strData = data.toString();
     const { isNotPre, bypassBuffer } = options;
 
-    // TODO: This drops the first part of the buffer... is that fully expected?
     if (process.platform === 'win32' && bypassBuffer === false) {
       this.outputBuffer += strData;
       strData = this.outputBuffer;
       const parts = strData.split('\r\n');
-      for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
+      for (let partIndex = 0; partIndex < parts.length; partIndex++) {
         const part = parts[partIndex];
         if (partIndex === parts.length - 1) {
           this.outputBuffer = part;
@@ -670,88 +662,6 @@ export class AppState {
     console.warn(error);
   }
 
-  /**
-   * Sets the editor value for a given editor. Deletes the value after
-   * accessing it.
-   *
-   * @param {EditorId} id
-   */
-  @action public getAndRemoveEditorValueBackup(
-    id: EditorId,
-  ): EditorBackup | null {
-    const value = this.closedPanels[id];
-    delete this.closedPanels[id];
-    return value;
-  }
-
-  @action public async setVisibleMosaics(visible: Array<EditorId>) {
-    const { editorMosaic, mosaicArrangement } = this;
-    const currentlyVisible = getVisibleMosaics(mosaicArrangement);
-
-    for (const id of DEFAULT_EDITORS) {
-      if (!visible.includes(id) && currentlyVisible.includes(id)) {
-        this.closedPanels[id] = getEditorBackup(id);
-        // if we have backup, remove active editor
-        editorMosaic.editors.delete(id);
-      }
-    }
-
-    const updatedArrangement = createMosaicArrangement(visible);
-    console.log(
-      `State: Setting visible mosaic panels`,
-      visible,
-      updatedArrangement,
-    );
-
-    this.mosaicArrangement = updatedArrangement;
-
-    // after the mosaicArrangement loads, we want to wait for the Mosaic editors to
-    // mount to ensure that we can load content into the editors as soon as they're
-    // declared visible.
-
-    await waitForEditorsToMount(visible);
-  }
-
-  /**
-   * Hides the panel for a given EditorId.
-   *
-   * @param {EditorId} id
-   */
-  @action public hideAndBackupMosaic(id: EditorId) {
-    const currentlyVisible = getVisibleMosaics(this.mosaicArrangement);
-    this.setVisibleMosaics(currentlyVisible.filter((v) => v !== id));
-  }
-
-  /**
-   * Removes the panel for a given custom EditorId.
-   *
-   * @param {EditorId} id
-   */
-  @action public removeCustomMosaic(id: EditorId) {
-    this.hideAndBackupMosaic(id);
-    this.editorMosaic.editors.delete(id);
-    this.customMosaics = this.customMosaics.filter((mosaic) => mosaic !== id);
-  }
-
-  /**
-   * Shows the editor value for a given editor.
-   *
-   * @param {EditorId} id
-   */
-  @action public showMosaic(id: EditorId) {
-    const currentlyVisible = getVisibleMosaics(this.mosaicArrangement);
-    this.setVisibleMosaics([...currentlyVisible, id]);
-  }
-
-  /**
-   * Resets editor view to default layout.
-   *
-   *
-   */
-  @action public resetEditorLayout() {
-    this.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
-  }
-
   @action public async addAcceleratorToBlock(acc: BlockableAccelerator) {
     if (!this.acceleratorsToBlock.includes(acc)) {
       this.acceleratorsToBlock = [...this.acceleratorsToBlock, acc];
diff --git a/src/utils/editor-backup.ts b/src/utils/editor-backup.ts
deleted file mode 100644
index 938ed9f2..00000000
--- a/src/utils/editor-backup.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { editor } from 'monaco-editor';
-
-import { EditorId } from '../interfaces';
-import { getEditorModel } from './editor-model';
-import { getEditorValue } from './editor-value';
-import { getEditorViewState } from './editor-viewstate';
-
-export interface EditorBackup {
-  value?: string;
-  model?: editor.ITextModel | null;
-  viewState?: editor.ICodeEditorViewState | null;
-}
-
-/**
- * Returns a backup for a given editor
- *
- * @param {EditorId} id
- * @returns {EditorBackup}
- */
-export function getEditorBackup(id: EditorId): EditorBackup {
-  return {
-    value: getEditorValue(id),
-    model: getEditorModel(id),
-    viewState: getEditorViewState(id),
-  };
-}
diff --git a/src/utils/editor-layout.ts b/src/utils/editor-layout.ts
deleted file mode 100644
index 3aa1027f..00000000
--- a/src/utils/editor-layout.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Attempts to update the layout of all editors. Exported as
- * a debounced version below.
- */
-function _updateEditorLayout() {
-  const { editorMosaic } = window.ElectronFiddle.app.state;
-  editorMosaic.editors.forEach((editor) => editor.layout());
-}
-
-let handle: number;
-export const updateEditorLayout = () => {
-  if (!handle) {
-    handle = window.setTimeout(() => {
-      _updateEditorLayout();
-      handle = 0;
-    }, 100);
-  }
-};
diff --git a/src/utils/editor-model.ts b/src/utils/editor-model.ts
deleted file mode 100644
index bf5c0861..00000000
--- a/src/utils/editor-model.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { editor } from 'monaco-editor';
-
-import { EditorId } from '../interfaces';
-
-/**
- * Return the model for a given editor
- *
- * @param {EditorId} id
- * @returns {editor.ITextModel | null}
- */
-export function getEditorModel(id: EditorId): editor.ITextModel | null {
-  if (window.ElectronFiddle?.app?.state) {
-    const { editorMosaic } = window.ElectronFiddle.app.state;
-
-    const editor = editorMosaic.editors.get(id);
-    if (editor) return editor.getModel();
-  }
-
-  return null;
-}
diff --git a/src/utils/editor-value.ts b/src/utils/editor-value.ts
deleted file mode 100644
index 7f2e9d40..00000000
--- a/src/utils/editor-value.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { EditorId } from '../interfaces';
-
-/**
- * Return the value for a given editor
- *
- * @param {EditorId} id
- * @returns {string}
- */
-export function getEditorValue(id: EditorId): string {
-  if (window.ElectronFiddle?.app?.state) {
-    const { closedPanels, editorMosaic } = window.ElectronFiddle.app.state;
-
-    const editor = editorMosaic.editors.get(id);
-    if (editor) return editor.getValue();
-
-    const backup = closedPanels[id];
-    if (backup?.value) return backup.value;
-  }
-
-  return '';
-}
diff --git a/src/utils/editor-viewstate.ts b/src/utils/editor-viewstate.ts
deleted file mode 100644
index 846a73d5..00000000
--- a/src/utils/editor-viewstate.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { editor } from 'monaco-editor';
-
-import { EditorId } from '../interfaces';
-
-/**
- * Returns the view state for a given editor.
- *
- * @export
- * @param {EditorId} id
- * @returns {(editor.ICodeEditorViewState | null)}
- */
-export function getEditorViewState(
-  id: EditorId,
-): editor.ICodeEditorViewState | null {
-  return (
-    window.ElectronFiddle?.app?.state.editorMosaic.editors
-      .get(id)
-      ?.saveViewState() || null
-  );
-}


Test patch:
diff --git a/tests/mocks/app.ts b/tests/mocks/app.ts
index afa567f0..e9f62199 100644
--- a/tests/mocks/app.ts
+++ b/tests/mocks/app.ts
@@ -10,7 +10,6 @@ import {
 export class AppMock {
   public setup = jest.fn();
   public replaceFiddle = jest.fn();
-  public setEditorValues = jest.fn();
   public getEditorValues = jest.fn().mockResolvedValue({
     [DefaultEditorId.main]: 'main-content',
     [DefaultEditorId.preload]: 'preload-content',
diff --git a/tests/mocks/editor-mosaic.ts b/tests/mocks/editor-mosaic.ts
index d7249921..035bcb70 100644
--- a/tests/mocks/editor-mosaic.ts
+++ b/tests/mocks/editor-mosaic.ts
@@ -1,10 +1,44 @@
 import { MonacoEditorMock } from './monaco-editor';
+import { MosaicNode } from 'react-mosaic-component';
+
 import { observable } from 'mobx';
 
 import { EditorId } from '../../src/interfaces';
+import { EditorBackup } from '../../src/renderer/editor-mosaic';
+
+import { objectDifference } from '../utils';
 
 export type Editor = MonacoEditorMock;
 
 export class EditorMosaicMock {
-  @observable public readonly editors: Map<EditorId, Editor> = new Map();
+  @observable public closedPanels: Record<EditorId, EditorBackup> = {};
+  @observable public customMosaics: EditorId[] = [];
+  @observable public editors: Map<EditorId, Editor> = new Map();
+  @observable public mosaicArrangement: MosaicNode<EditorId> | null = null;
+
+  public getAndRemoveEditorValueBackup = jest.fn();
+  public getEditorBackup = jest
+    .fn()
+    .mockImplementation((id) => this.closedPanels[id]);
+  public getEditorValue = jest
+    .fn()
+    .mockImplementation((id) => this.editors.get(id)?.getValue() || '');
+  public getEditorViewState = jest
+    .fn()
+    .mockImplementation((id) => this.editors.get(id)?.saveViewState() || null);
+  public layout = jest.fn().mockImplementation(() => {
+    this.editors.forEach((editor) => editor.layout());
+  });
+  public hideAndBackupMosaic = jest.fn();
+  public removeCustomMosaic = jest.fn();
+  public resetEditorLayout = jest.fn();
+  public set = jest.fn();
+  public setVisibleMosaics = jest.fn();
+  public showMosaic = jest.fn();
+  public values = jest.fn().mockReturnValue({});
+
+  public toJSON() {
+    const o = objectDifference(this, new EditorMosaicMock());
+    return Object.keys(o).length === 0 ? 'default EditorMosaicMock' : o;
+  }
 }
diff --git a/tests/mocks/monaco-editor.ts b/tests/mocks/monaco-editor.ts
index fda32ef1..286aee22 100644
--- a/tests/mocks/monaco-editor.ts
+++ b/tests/mocks/monaco-editor.ts
@@ -1,3 +1,5 @@
+import { objectDifference } from '../utils';
+
 export class MonacoEditorMock {
   public action = {
     isSupported: jest.fn(),
@@ -34,4 +36,9 @@ export class MonacoEditorMock {
   public updateOptions = jest.fn();
 
   private listener: any;
+
+  public toJSON() {
+    const o = objectDifference(this, new MonacoEditorMock());
+    return Object.keys(o).length === 0 ? 'default MonacoEditorMock' : o;
+  }
 }
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index 76e9c1c9..8c8a8e75 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -1,17 +1,15 @@
-import { observable, toJS } from 'mobx';
-import { MosaicNode } from 'react-mosaic-component';
+import { observable } from 'mobx';
 
 import {
   BlockableAccelerator,
   DefaultEditorId,
-  EditorId,
   ElectronReleaseChannel,
   GenericDialogOptions,
   GistActionState,
   RunnableVersion,
 } from '../../src/interfaces';
-import { EditorBackup } from '../../src/utils/editor-backup';
 
+import { objectDifference } from '../utils';
 import { BisectorMock } from './bisector';
 import { EditorMosaicMock } from './editor-mosaic';
 import { MonacoEditorMock } from './monaco-editor';
@@ -21,9 +19,7 @@ export class StateMock {
   @observable public acceleratorsToBlock: BlockableAccelerator[] = [];
   @observable public activeGistAction = GistActionState.none;
   @observable public channelsToShow: ElectronReleaseChannel[] = [];
-  @observable public closedPanels: Partial<
-    Record<EditorId, EditorBackup | true>
-  > = {};
+  @observable public editorMosaic = new EditorMosaicMock();
   @observable public environmentVariables: string[] = [];
   @observable public executionFlags: string[] = [];
   @observable public genericDialogLastInput: string | null = null;
@@ -57,11 +53,6 @@ export class StateMock {
   @observable public versions: Record<string, RunnableVersion>;
   @observable public versionsToShow: RunnableVersion[] = [];
 
-  // editor mosaic
-  @observable public customMosaics: EditorId[] = [];
-  @observable public editorMosaic = new EditorMosaicMock();
-  @observable public mosaicArrangement: MosaicNode<EditorId> | null = null;
-
   public Bisector = new BisectorMock();
   public addAcceleratorToBlock = jest.fn();
   public addLocalVersion = jest.fn();
@@ -69,17 +60,15 @@ export class StateMock {
   public currentElectronVersion = new VersionsMock().mockVersionsArray.shift()!;
   public disableTour = jest.fn();
   public downloadVersion = jest.fn();
-  public getAndRemoveEditorValueBackup = jest.fn();
   public getName = jest.fn();
   public hasVersion = jest
     .fn()
     .mockImplementation((version: string) => !!this.versions[version]);
-  public hideAndBackupMosaic = jest.fn();
   public hideChannels = jest.fn();
   public pushError = jest.fn();
   public pushOutput = jest.fn();
+  public flushOutput = jest.fn();
   public removeAcceleratorToBlock = jest.fn();
-  public removeCustomMosaic = jest.fn();
   public removeVersion = jest.fn();
   public setGenericDialogOptions = jest.fn().mockReturnValue({});
   public setTheme = jest.fn();
@@ -87,10 +76,8 @@ export class StateMock {
     this.version = version;
     this.currentElectronVersion = this.versions[version];
   });
-  public setVisibleMosaics = jest.fn();
   public showChannels = jest.fn();
   public showCustomEditorDialog = jest.fn();
-  public showMosaic = jest.fn();
   public signOutGitHub = jest.fn();
   public toggleAddVersionDialog = jest.fn();
   public toggleAuthDialog = jest.fn();
@@ -129,31 +116,18 @@ export class StateMock {
   // Snapshots get very "noisy" when they include the entire Mock's details.
   // We can avoid most of that noise by hiding fields that hold default values.
   public toJSON() {
-    const defaultValues = new StateMock();
-    const serialize = (input: any) => JSON.stringify(toJS(input));
-    const isDefaultValue = (key: string, value: any) =>
-      serialize(value) === serialize(defaultValues[key]);
+    const o = objectDifference(this, new StateMock());
+
     const terserRunnable = (ver: RunnableVersion) =>
       [ver.version, ver.source, ver.state].join(' ');
-
-    const o = {};
-    for (const entry of Object.entries(this)) {
-      const key = entry[0];
-      let val = toJS(entry[1]);
-
-      // omit any fields that have the default value
-      if (isDefaultValue(key, val)) continue;
-
-      // make some verbose properties a little terser
+    for (const [key, val] of Object.entries(o)) {
       if (key == 'currentElectronVersion') {
-        val = terserRunnable(val);
+        o[key] = terserRunnable(val) as any;
       } else if (key === 'versions') {
-        val = Object.values(val).map(terserRunnable);
+        o[key] = Object.values(val).map(terserRunnable) as any;
       } else if (key === 'versionsToShow') {
-        val = val.map(terserRunnable);
+        o[key] = val.map(terserRunnable);
       }
-
-      o[key] = val;
     }
 
     return Object.keys(o).length === 0 ? 'default StateMock' : o;
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index d5c267fa..40bbbdbb 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -1,12 +1,8 @@
 import { App } from '../../src/renderer/app';
-import { EditorBackup } from '../../src/utils/editor-backup';
 import { waitFor } from '../../src/utils/wait-for';
-import {
-  DefaultEditorId,
-  EditorValues,
-  PACKAGE_NAME,
-} from '../../src/interfaces';
+import { DefaultEditorId } from '../../src/interfaces';
 import { defaultDark, defaultLight } from '../../src/renderer/themes-defaults';
+import { createEditorValues } from '../mocks/mocks';
 
 jest.mock('../../src/renderer/components/header', () => ({
   Header: () => 'Header;',
@@ -21,7 +17,6 @@ jest.mock('../../src/renderer/components/output-editors-wrapper', () => ({
 describe('App component', () => {
   let app: App;
   let ElectronFiddle: any;
-  let editorMosaic: any;
 
   beforeAll(() => {
     document.body.innerHTML = '<div id="app" />';
@@ -29,7 +24,6 @@ describe('App component', () => {
 
   beforeEach(() => {
     ({ ElectronFiddle } = window as any);
-    ({ editorMosaic } = ElectronFiddle.app.state);
 
     // make a real App and inject it into the mocks
     app = new App();
@@ -74,35 +68,17 @@ describe('App component', () => {
 
   describe('getEditorValues()', () => {
     it('gets values', async () => {
-      const value = 'editor-value';
-      for (const editor of editorMosaic.editors.values()) {
-        editor.getValue.mockReturnValue(value);
-      }
+      const values = createEditorValues();
+      (app.state.editorMosaic.values as jest.Mock).mockReturnValue(values);
       const b = await app.getEditorValues({});
-      expect(b[DefaultEditorId.html]).toBe(value);
-      expect(b[DefaultEditorId.main]).toBe(value);
-      expect(b[DefaultEditorId.renderer]).toBe(value);
-      expect(b[PACKAGE_NAME]).toBeTruthy();
-      expect(JSON.parse(b[PACKAGE_NAME]!)).toBeTruthy();
-    });
-
-    it('throws if the Fiddle object is not present', async () => {
-      const app = new App();
-
-      (window as any).ElectronFiddle = null;
-      let threw = false;
-      try {
-        await app.getEditorValues({});
-      } catch (error) {
-        threw = true;
-      }
-
-      expect(threw).toBe(true);
+      expect(b).toStrictEqual(values);
     });
   });
 
   describe('replaceFiddle()', () => {
     it('sets editor values and source info', async () => {
+      const { state } = app;
+
       const editorValues = {
         [DefaultEditorId.html]: 'html-value',
         [DefaultEditorId.main]: 'main-value',
@@ -111,73 +87,15 @@ describe('App component', () => {
         [DefaultEditorId.preload]: '',
       };
 
-      app.setEditorValues = jest.fn();
       await app.replaceFiddle(editorValues, {
         gistId: 'gistId',
         templateName: 'templateName',
         filePath: 'localPath',
       });
-      expect(app.setEditorValues).toHaveBeenCalledWith(editorValues);
-      expect(app.state.gistId).toBe('gistId');
-      expect(app.state.templateName).toBe('templateName');
-      expect(app.state.localPath).toBe('localPath');
-    });
-
-    it('only shows mosaic for non-empty editor contents', async () => {
-      const editorValues = {
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
-        [DefaultEditorId.renderer]: 'renderer-value',
-        [DefaultEditorId.css]: '/* Empty */',
-        [DefaultEditorId.preload]: '',
-      };
-
-      const gistId = 'gistId';
-      await app.replaceFiddle(editorValues, { gistId });
-      expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
-        DefaultEditorId.main,
-        DefaultEditorId.renderer,
-        DefaultEditorId.html,
-      ]);
-    });
-
-    it('shows visible mosaics for non-empty editor contents with custom mosaics', async () => {
-      const file = 'file.js';
-      const editorValues = {
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
-        [DefaultEditorId.renderer]: 'renderer-value',
-        [DefaultEditorId.css]: '/* Empty */',
-        [DefaultEditorId.preload]: '',
-        [file]: 'file-value',
-      };
-
-      const gistId = 'gistId';
-      await app.replaceFiddle(editorValues, { gistId });
-      expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
-        DefaultEditorId.main,
-        DefaultEditorId.renderer,
-        DefaultEditorId.html,
-        file,
-      ]);
-    });
-
-    it('shows visible mosaics in the correct pre-defined order', async () => {
-      // this order is defined inside the replaceFiddle() function
-      const editorValues = {
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
-        [DefaultEditorId.renderer]: 'renderer-value',
-        [DefaultEditorId.css]: 'css-value',
-      };
-      const gistId = 'gistId';
-      await app.replaceFiddle(editorValues, { gistId });
-      expect(app.state.setVisibleMosaics).toHaveBeenCalledWith([
-        DefaultEditorId.main,
-        DefaultEditorId.renderer,
-        DefaultEditorId.html,
-        DefaultEditorId.css,
-      ]);
+      expect(state.editorMosaic.set).toHaveBeenCalledWith(editorValues);
+      expect(state.gistId).toBe('gistId');
+      expect(state.templateName).toBe('templateName');
+      expect(state.localPath).toBe('localPath');
     });
 
     it('unsets state of previous source when called', (done) => {
@@ -242,11 +160,12 @@ describe('App component', () => {
 
     describe('when current Fiddle is unsaved and prompt appears', () => {
       it('takes no action if prompt is rejected', (done) => {
-        app.setEditorValues = jest.fn();
-        app.state.isUnsaved = true;
-        expect(app.state.localPath).toBeUndefined();
-        expect(app.state.gistId).toBe('');
-        expect(app.state.templateName).toBeUndefined();
+        const { state } = app;
+
+        state.isUnsaved = true;
+        expect(state.localPath).toBeUndefined();
+        expect(state.gistId).toBe('');
+        expect(state.templateName).toBeUndefined();
 
         app
           .replaceFiddle(
@@ -258,23 +177,23 @@ describe('App component', () => {
             },
           )
           .then(() => {
-            expect(app.setEditorValues).not.toHaveBeenCalled();
-            expect(app.state.localPath).toBeUndefined();
-            expect(app.state.gistId).toBe('');
-            expect(app.state.templateName).toBeUndefined();
+            expect(state.editorMosaic.set).not.toHaveBeenCalled();
+            expect(state.localPath).toBeUndefined();
+            expect(state.gistId).toBe('');
+            expect(state.templateName).toBeUndefined();
             done();
           });
 
         setTimeout(() => {
-          expect(app.state.isGenericDialogShowing).toBe(true);
-          app.state.genericDialogLastResult = false;
-          app.state.isGenericDialogShowing = false;
+          expect(state.isGenericDialogShowing).toBe(true);
+          state.genericDialogLastResult = false;
+          state.isGenericDialogShowing = false;
         });
       });
 
       it('sets editor values and source info if prompt is accepted', (done) => {
-        app.setEditorValues = jest.fn();
-        app.state.isUnsaved = true;
+        const { state } = app;
+        state.isUnsaved = true;
 
         const editorValues = {
           [DefaultEditorId.html]: 'html-value',
@@ -289,90 +208,19 @@ describe('App component', () => {
             filePath: 'localPath',
           })
           .then(() => {
-            expect(app.setEditorValues).toHaveBeenCalledWith(editorValues);
-            expect(app.state.gistId).toBe('gistId');
-            expect(app.state.templateName).toBe('templateName');
-            expect(app.state.localPath).toBe('localPath');
+            expect(state.editorMosaic.set).toHaveBeenCalledWith(editorValues);
+            expect(state.gistId).toBe('gistId');
+            expect(state.templateName).toBe('templateName');
+            expect(state.localPath).toBe('localPath');
             done();
           });
 
         setTimeout(() => {
-          expect(app.state.isGenericDialogShowing).toBe(true);
-          app.state.genericDialogLastResult = true;
-          app.state.isGenericDialogShowing = false;
-        });
-      });
-    });
-  });
-
-  describe('setEditorValues()', () => {
-    it('attempts to set values', () => {
-      const editorValues: EditorValues = {
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
-        [DefaultEditorId.renderer]: 'renderer-value',
-      } as const;
-      app.setEditorValues(editorValues);
-
-      for (const [filename, value] of Object.entries(editorValues)) {
-        const editor = editorMosaic.editors.get(filename);
-        expect(editor.setValue).toHaveBeenCalledWith(value);
-      }
-    });
-
-    it('attempts to set values for closed editors', () => {
-      editorMosaic.editors.delete(DefaultEditorId.main);
-
-      (app.state.closedPanels as any)[DefaultEditorId.main] = {
-        model: { setValue: jest.fn() },
-      };
-      app.state.closedPanels[DefaultEditorId.preload] = {};
-      app.state.closedPanels[DefaultEditorId.css] = {};
-
-      app.setEditorValues({
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
-        [DefaultEditorId.renderer]: 'renderer-value',
-        [DefaultEditorId.preload]: 'preload-value',
-        [DefaultEditorId.css]: 'css-value',
-      });
-
-      expect(
-        (app.state.closedPanels[DefaultEditorId.main] as EditorBackup)!.model!
-          .setValue,
-      ).toHaveBeenCalledWith('main-value');
-      expect(app.state.closedPanels[DefaultEditorId.preload]).toEqual({
-        value: 'preload-value',
-      });
-      expect(app.state.closedPanels[DefaultEditorId.css]).toEqual({
-        value: 'css-value',
-      });
-    });
-
-    it('throws if the Fiddle object is not present', async () => {
-      (window as any).ElectronFiddle = null;
-      let threw = false;
-      try {
-        await app.setEditorValues({
-          [DefaultEditorId.html]: '',
-          [DefaultEditorId.main]: '',
-          [DefaultEditorId.renderer]: '',
+          expect(state.isGenericDialogShowing).toBe(true);
+          state.genericDialogLastResult = true;
+          state.isGenericDialogShowing = false;
         });
-      } catch (error) {
-        threw = true;
-      }
-
-      expect(threw).toBe(true);
-    });
-
-    it('does not set a value if none passed in', async () => {
-      await app.setEditorValues({
-        [DefaultEditorId.html]: 'html-value',
-        [DefaultEditorId.main]: 'main-value',
       });
-
-      const editor = editorMosaic.editors.get(DefaultEditorId.renderer);
-      expect(editor.setValue).not.toHaveBeenCalled();
     });
   });
 
diff --git a/tests/renderer/components/__snapshots__/commands-editors-spec.tsx.snap b/tests/renderer/components/__snapshots__/commands-editors-spec.tsx.snap
index 64e98158..814e8463 100644
--- a/tests/renderer/components/__snapshots__/commands-editors-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/commands-editors-spec.tsx.snap
@@ -70,6 +70,7 @@ Array [
       disabled={false}
       icon="grid-view"
       multiline={false}
+      onClick={[MockFunction]}
       popoverProps={Object {}}
       shouldDismissPopover={true}
       text="Reset Layout"
@@ -153,6 +154,7 @@ exports[`EditorDropdown component renders 1`] = `
             disabled={false}
             icon="grid-view"
             multiline={false}
+            onClick={[MockFunction]}
             popoverProps={Object {}}
             shouldDismissPopover={true}
             text="Reset Layout"
@@ -260,6 +262,7 @@ exports[`EditorDropdown component renders the extra button if the FIDDLE_DOCS_DE
             disabled={false}
             icon="grid-view"
             multiline={false}
+            onClick={[MockFunction]}
             popoverProps={Object {}}
             shouldDismissPopover={true}
             text="Reset Layout"
diff --git a/tests/renderer/components/__snapshots__/editors-spec.tsx.snap b/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
index 2b290b21..6159fea7 100644
--- a/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/editors-spec.tsx.snap
@@ -915,20 +915,22 @@ exports[`Editors component renders a toolbar 1`] = `
       <MaximizeButton
         appState={
           Object {
-            "mosaicArrangement": Object {
-              "direction": "row",
-              "first": Object {
-                "direction": "column",
-                "first": "main.js",
-                "second": "renderer.js",
-              },
-              "second": Object {
-                "direction": "column",
-                "first": "preload.js",
+            "editorMosaic": Object {
+              "mosaicArrangement": Object {
+                "direction": "row",
+                "first": Object {
+                  "direction": "column",
+                  "first": "main.js",
+                  "second": "renderer.js",
+                },
                 "second": Object {
                   "direction": "column",
-                  "first": "index.html",
-                  "second": "styles.css",
+                  "first": "preload.js",
+                  "second": Object {
+                    "direction": "column",
+                    "first": "index.html",
+                    "second": "styles.css",
+                  },
                 },
               },
             },
@@ -939,20 +941,22 @@ exports[`Editors component renders a toolbar 1`] = `
       <RemoveButton
         appState={
           Object {
-            "mosaicArrangement": Object {
-              "direction": "row",
-              "first": Object {
-                "direction": "column",
-                "first": "main.js",
-                "second": "renderer.js",
-              },
-              "second": Object {
-                "direction": "column",
-                "first": "preload.js",
+            "editorMosaic": Object {
+              "mosaicArrangement": Object {
+                "direction": "row",
+                "first": Object {
+                  "direction": "column",
+                  "first": "main.js",
+                  "second": "renderer.js",
+                },
                 "second": Object {
                   "direction": "column",
-                  "first": "index.html",
-                  "second": "styles.css",
+                  "first": "preload.js",
+                  "second": Object {
+                    "direction": "column",
+                    "first": "index.html",
+                    "second": "styles.css",
+                  },
                 },
               },
             },
diff --git a/tests/renderer/components/commands-editors-spec.tsx b/tests/renderer/components/commands-editors-spec.tsx
index 6067a7a5..80b8f5b3 100644
--- a/tests/renderer/components/commands-editors-spec.tsx
+++ b/tests/renderer/components/commands-editors-spec.tsx
@@ -37,22 +37,25 @@ describe('EditorDropdown component', () => {
   it('handles a click for an item', () => {
     const wrapper = mount(<EditorDropdown appState={store as any} />);
     const dropdown = wrapper.instance() as EditorDropdown;
+    const { editorMosaic } = store;
 
     dropdown.onItemClick({
       currentTarget: { id: DefaultEditorId.html },
     } as any);
-    expect(store.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
-    expect(store.showMosaic).toHaveBeenCalledTimes(0);
+    expect(editorMosaic.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.showMosaic).toHaveBeenCalledTimes(0);
 
     dropdown.onItemClick({
       currentTarget: { id: DefaultEditorId.main },
     } as any);
-    expect(store.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
-    expect(store.showMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.showMosaic).toHaveBeenCalledTimes(1);
   });
 
   it('disables hide button if only one editor open', () => {
-    store.mosaicArrangement = DefaultEditorId.html;
+    const { editorMosaic } = store;
+
+    editorMosaic.mosaicArrangement = DefaultEditorId.html;
     (getVisibleMosaics as jest.Mock).mockReturnValue([DefaultEditorId.html]);
 
     const wrapper = mount(<EditorDropdown appState={store as any} />);
@@ -63,6 +66,7 @@ describe('EditorDropdown component', () => {
   });
 
   it('can add a valid custom editor', async () => {
+    const { editorMosaic } = store;
     const file = 'file.js';
 
     store.showCustomEditorDialog = jest
@@ -85,12 +89,13 @@ describe('EditorDropdown component', () => {
       wantsInput: true,
     });
 
-    expect(store.showMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.showMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.customMosaics).toEqual([file]);
     expect(store.toggleGenericDialog).toHaveBeenCalledTimes(1);
-    expect(store.customMosaics).toEqual([file]);
   });
 
   it('errors when trying to add a duplicate custom editor', async () => {
+    const { editorMosaic } = store;
     const badFile = 'main.js';
 
     store.showCustomEditorDialog = jest
@@ -120,11 +125,12 @@ describe('EditorDropdown component', () => {
     });
 
     expect(store.toggleGenericDialog).toHaveBeenCalledTimes(2);
-    expect(store.showMosaic).toHaveBeenCalledTimes(0);
-    expect(store.customMosaics).toEqual([]);
+    expect(editorMosaic.showMosaic).toHaveBeenCalledTimes(0);
+    expect(editorMosaic.customMosaics).toEqual([]);
   });
 
   it('errors when trying to add an invalid custom editor', async () => {
+    const { editorMosaic } = store;
     const badFile = 'bad.bad';
 
     store.showCustomEditorDialog = jest
@@ -154,14 +160,15 @@ describe('EditorDropdown component', () => {
       cancel: undefined,
     });
 
-    expect(store.showMosaic).toHaveBeenCalledTimes(0);
+    expect(editorMosaic.customMosaics).toEqual([]);
+    expect(editorMosaic.showMosaic).toHaveBeenCalledTimes(0);
     expect(store.toggleGenericDialog).toHaveBeenCalledTimes(2);
-    expect(store.customMosaics).toEqual([]);
   });
 
   it('can remove a custom editor', () => {
+    const { editorMosaic } = store;
     const file = 'file.js';
-    store.customMosaics = [file];
+    editorMosaic.customMosaics = [file];
 
     const wrapper = mount(<EditorDropdown appState={store as any} />);
     const dropdown = wrapper.instance() as EditorDropdown;
@@ -170,6 +177,6 @@ describe('EditorDropdown component', () => {
       currentTarget: { id: file },
     } as any);
 
-    expect(store.removeCustomMosaic).toHaveBeenCalledTimes(1);
+    expect(editorMosaic.removeCustomMosaic).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/tests/renderer/components/editor-spec.tsx b/tests/renderer/components/editor-spec.tsx
index d2ada760..4e440aa1 100644
--- a/tests/renderer/components/editor-spec.tsx
+++ b/tests/renderer/components/editor-spec.tsx
@@ -109,7 +109,7 @@ describe('Editor component', () => {
 
     describe('backups', async () => {
       it('attempts to restore a backup if contains a model', async () => {
-        store.getAndRemoveEditorValueBackup.mockReturnValueOnce({
+        store.editorMosaic.getAndRemoveEditorValueBackup.mockReturnValueOnce({
           model: true,
           viewState: true,
         });
@@ -134,7 +134,7 @@ describe('Editor component', () => {
       });
 
       it('attempts to restore a backup if contains a string value', async () => {
-        store.getAndRemoveEditorValueBackup.mockReturnValueOnce({
+        store.editorMosaic.getAndRemoveEditorValueBackup.mockReturnValueOnce({
           value: 'hello',
         });
 
diff --git a/tests/renderer/components/editors-non-ideal-state-spec.tsx b/tests/renderer/components/editors-non-ideal-state-spec.tsx
index eb22a896..e3eb5d32 100644
--- a/tests/renderer/components/editors-non-ideal-state-spec.tsx
+++ b/tests/renderer/components/editors-non-ideal-state-spec.tsx
@@ -1,17 +1,24 @@
 import { mount } from 'enzyme';
 
+import { StateMock } from '../../mocks/mocks';
+
 import { renderNonIdealState } from '../../../src/renderer/components/editors-non-ideal-state';
 
 describe('renderNonIdealState()', () => {
+  let state: StateMock;
+
+  beforeEach(() => {
+    ({ state } = (window as any).ElectronFiddle.app);
+  });
+
   it('renders a non-ideal state', () => {
     expect(renderNonIdealState({} as any)).toMatchSnapshot();
   });
 
   it('handles a click', () => {
-    const mockState = { setVisibleMosaics: jest.fn() };
-    const wrapper = mount(renderNonIdealState(mockState as any));
+    const wrapper = mount(renderNonIdealState(state as any));
     wrapper.find('button').simulate('click');
 
-    expect(mockState.setVisibleMosaics).toHaveBeenCalledTimes(1);
+    expect(state.editorMosaic.setVisibleMosaics).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/tests/renderer/components/editors-spec.tsx b/tests/renderer/components/editors-spec.tsx
index 1b650564..a71c7d22 100644
--- a/tests/renderer/components/editors-spec.tsx
+++ b/tests/renderer/components/editors-spec.tsx
@@ -8,7 +8,6 @@ import { IpcEvents } from '../../../src/ipc-events';
 import { createMosaicArrangement } from '../../../src/utils/editors-mosaic-arrangement';
 import { getFocusedEditor } from '../../../src/utils/focused-editor';
 import { ipcRendererManager } from '../../../src/renderer/ipc';
-import { updateEditorLayout } from '../../../src/utils/editor-layout';
 
 import {
   EditorMosaicMock,
@@ -30,10 +29,6 @@ jest.mock('../../../src/utils/focused-editor', () => ({
   getFocusedEditor: jest.fn(),
 }));
 
-jest.mock('../../../src/utils/editor-layout', () => ({
-  updateEditorLayout: jest.fn(),
-}));
-
 describe('Editors component', () => {
   let ElectronFiddle: any;
   let monaco: any;
@@ -44,7 +39,9 @@ describe('Editors component', () => {
     ({ ElectronFiddle } = window as any);
     ({ monaco, state: store } = ElectronFiddle.app);
     ({ editorMosaic } = store);
-    store.mosaicArrangement = createMosaicArrangement(DEFAULT_EDITORS);
+    store.editorMosaic.mosaicArrangement = createMosaicArrangement(
+      DEFAULT_EDITORS,
+    );
   });
 
   it('renders', () => {
@@ -109,7 +106,7 @@ describe('Editors component', () => {
   });
 
   it('does not render toolbar controls if only one editor exists', () => {
-    store.mosaicArrangement = DefaultEditorId.main;
+    store.editorMosaic.mosaicArrangement = DefaultEditorId.main;
     const wrapper = shallow(<Editors appState={store as any} />);
     const instance: Editors = wrapper.instance() as any;
     const toolbar = instance.renderToolbar(
@@ -120,23 +117,15 @@ describe('Editors component', () => {
     expect(toolbar).toMatchSnapshot();
   });
 
-  it('componentWillUnmount() unsubscribes the layout reaction', () => {
-    const wrapper = shallow(<Editors appState={store as any} />);
-    const instance: Editors = wrapper.instance() as any;
-    (instance as any).disposeLayoutAutorun = jest.fn();
-
-    wrapper.unmount();
-
-    expect(instance.disposeLayoutAutorun).toHaveBeenCalledTimes(1);
-  });
-
   it('onChange() updates the mosaic arrangement in the appState', () => {
     const wrapper = shallow(<Editors appState={store as any} />);
     const instance: Editors = wrapper.instance() as any;
 
     instance.onChange({ testArrangement: true } as any);
 
-    expect(store.mosaicArrangement).toEqual({ testArrangement: true });
+    expect(store.editorMosaic.mosaicArrangement).toEqual({
+      testArrangement: true,
+    });
   });
 
   describe('IPC commands', () => {
@@ -303,12 +292,4 @@ describe('Editors component', () => {
       expect(spy).toHaveBeenCalledWith({ focused: id });
     });
   });
-
-  describe('disposeLayoutAutorun()', () => {
-    it('automatically updates the layout when the mosaic arrangement changes', () => {
-      shallow(<Editors appState={store as any} />);
-      store.mosaicArrangement = DefaultEditorId.main;
-      expect(updateEditorLayout).toHaveBeenCalledTimes(1);
-    });
-  });
 });
diff --git a/tests/renderer/components/editors-toolbar-button-spec.tsx b/tests/renderer/components/editors-toolbar-button-spec.tsx
index 0e7621e4..bf2b6d7d 100644
--- a/tests/renderer/components/editors-toolbar-button-spec.tsx
+++ b/tests/renderer/components/editors-toolbar-button-spec.tsx
@@ -7,6 +7,8 @@ import {
   RemoveButton,
 } from '../../../src/renderer/components/editors-toolbar-button';
 
+import { StateMock } from '../../mocks/mocks';
+
 let mockContext: any = {};
 
 jest.mock('react-mosaic-component', () => {
@@ -26,7 +28,7 @@ jest.mock('react-mosaic-component', () => {
 });
 
 describe('Editor toolbar button component', () => {
-  let store: any = {};
+  let store: StateMock;
 
   beforeAll(() => {
     mockContext = {
@@ -48,15 +50,13 @@ describe('Editor toolbar button component', () => {
       mosaicId: 'test',
     };
 
-    store = {
-      hideAndBackupMosaic: jest.fn(),
-    };
+    ({ state: store } = (window as any).ElectronFiddle.app);
   });
 
   describe('MaximizeButton', () => {
     it('renders', () => {
       const wrapper = shallow(
-        <MaximizeButton id={DefaultEditorId.main} appState={store} />,
+        <MaximizeButton id={DefaultEditorId.main} appState={store as any} />,
         {
           context: mockContext,
         },
@@ -67,7 +67,7 @@ describe('Editor toolbar button component', () => {
 
     it('handles a click', () => {
       const wrapper = shallow(
-        <MaximizeButton id={DefaultEditorId.main} appState={store} />,
+        <MaximizeButton id={DefaultEditorId.main} appState={store as any} />,
         {
           context: mockContext,
         },
@@ -83,7 +83,7 @@ describe('Editor toolbar button component', () => {
   describe('RemoveButton', () => {
     it('renders', () => {
       const wrapper = shallow(
-        <RemoveButton id={DefaultEditorId.main} appState={store} />,
+        <RemoveButton id={DefaultEditorId.main} appState={store as any} />,
         {
           context: mockContext,
         },
@@ -93,14 +93,14 @@ describe('Editor toolbar button component', () => {
 
     it('handles a click', () => {
       const wrapper = shallow(
-        <RemoveButton id={DefaultEditorId.main} appState={store} />,
+        <RemoveButton id={DefaultEditorId.main} appState={store as any} />,
         {
           context: mockContext,
         },
       );
 
       wrapper.dive().dive().find('button').simulate('click');
-      expect(store.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
+      expect(store.editorMosaic.hideAndBackupMosaic).toHaveBeenCalledTimes(1);
     });
   });
 });
diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts
new file mode 100644
index 00000000..b559db94
--- /dev/null
+++ b/tests/renderer/editor-mosaic-spec.ts
@@ -0,0 +1,388 @@
+import {
+  DEFAULT_EDITORS,
+  DefaultEditorId,
+  EditorId,
+  EditorValues,
+} from '../../src/interfaces';
+import { EditorBackup, EditorMosaic } from '../../src/renderer/editor-mosaic';
+import {
+  DEFAULT_MOSAIC_ARRANGEMENT,
+  SORTED_EDITORS,
+} from '../../src/renderer/constants';
+import { compareEditors } from '../../src/utils/editor-utils';
+import { createMosaicArrangement } from '../../src/utils/editors-mosaic-arrangement';
+import { MonacoEditorMock, createEditorValues } from '../mocks/mocks';
+import { waitForEditorsToMount } from '../../src/utils/editor-mounted';
+import { waitFor } from '../../src/utils/wait-for';
+
+jest.mock('../../src/utils/editor-mounted', () => ({
+  waitForEditorsToMount: jest.fn(),
+}));
+
+describe('EditorMosaic', () => {
+  let editorMosaic: EditorMosaic;
+
+  beforeEach(() => {
+    // inject a real EditorMosaic into our mock scaffolding
+    editorMosaic = new EditorMosaic();
+    (window as any).ElectronFiddle.app.state.editorMosaic = editorMosaic;
+  });
+
+  describe('getAndRemoveEditorValueBackup()', () => {
+    it('returns null if there is no backup', () => {
+      const result = editorMosaic.getAndRemoveEditorValueBackup(
+        DefaultEditorId.main,
+      );
+      expect(result).toEqual(undefined);
+    });
+
+    it('returns and deletes a backup if there is one', () => {
+      editorMosaic.closedPanels[DefaultEditorId.main] = {
+        testBackup: true,
+      } as any;
+      const result = editorMosaic.getAndRemoveEditorValueBackup(
+        DefaultEditorId.main,
+      );
+      expect(result).toEqual({ testBackup: true });
+      expect(editorMosaic.closedPanels[DefaultEditorId.main]).toBeUndefined();
+    });
+  });
+
+  describe('setVisibleMosaics()', () => {
+    it('updates the visible editors and creates a backup', async () => {
+      editorMosaic.mosaicArrangement = createMosaicArrangement(DEFAULT_EDITORS);
+      editorMosaic.closedPanels = {};
+      editorMosaic.customMosaics = [];
+      await editorMosaic.setVisibleMosaics([DefaultEditorId.main]);
+
+      // we just need to mock something truthy here
+      editorMosaic.editors.set(DefaultEditorId.main, {} as any);
+
+      expect(editorMosaic.mosaicArrangement).toEqual(DefaultEditorId.main);
+      expect(editorMosaic.closedPanels[DefaultEditorId.renderer]).toBeTruthy();
+      expect(editorMosaic.closedPanels[DefaultEditorId.html]).toBeTruthy();
+      expect(editorMosaic.closedPanels[DefaultEditorId.main]).toBeUndefined();
+    });
+  });
+
+  describe('removeCustomMosaic()', () => {
+    it('removes a given custom mosaic', () => {
+      const file = 'file.js';
+      editorMosaic.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
+      editorMosaic.customMosaics = [file];
+
+      editorMosaic.removeCustomMosaic(file);
+
+      expect(editorMosaic.customMosaics).toEqual([]);
+    });
+  });
+
+  describe('hideAndBackupMosaic()', () => {
+    it('hides a given editor and creates a backup', () => {
+      editorMosaic.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
+      editorMosaic.closedPanels = {};
+      editorMosaic.hideAndBackupMosaic(SORTED_EDITORS[0]);
+
+      expect(editorMosaic.mosaicArrangement).toEqual({
+        direction: 'row',
+        first: SORTED_EDITORS[1],
+        second: {
+          direction: 'column',
+          first: SORTED_EDITORS[2],
+          second: SORTED_EDITORS[3],
+        },
+      });
+      expect(editorMosaic.closedPanels[DefaultEditorId.main]).toBeTruthy();
+      expect(
+        editorMosaic.closedPanels[DefaultEditorId.renderer],
+      ).toBeUndefined();
+      expect(editorMosaic.closedPanels[DefaultEditorId.html]).toBeUndefined();
+    });
+  });
+
+  describe('showMosaic()', () => {
+    it('shows a given editor', () => {
+      editorMosaic.mosaicArrangement = DefaultEditorId.main;
+      editorMosaic.showMosaic(DefaultEditorId.html);
+
+      expect(editorMosaic.mosaicArrangement).toEqual({
+        direction: 'row',
+        first: DefaultEditorId.main,
+        second: DefaultEditorId.html,
+      });
+    });
+  });
+
+  describe('resetEditorLayout()', () => {
+    it('Puts editors in default arrangement', () => {
+      editorMosaic.hideAndBackupMosaic(SORTED_EDITORS[0]);
+
+      expect(editorMosaic.mosaicArrangement).toEqual({
+        direction: 'row',
+        first: SORTED_EDITORS[1],
+        second: {
+          direction: 'column',
+          first: SORTED_EDITORS[2],
+          second: SORTED_EDITORS[3],
+        },
+      });
+
+      editorMosaic.resetEditorLayout();
+
+      expect(editorMosaic.mosaicArrangement).toEqual(
+        DEFAULT_MOSAIC_ARRANGEMENT,
+      );
+    });
+  });
+
+  describe('values()', () => {
+    it('gets values', async () => {
+      (waitForEditorsToMount as jest.Mock).mockResolvedValue(true);
+      const values = createEditorValues();
+      await editorMosaic.set(values);
+      expect(editorMosaic.values()).toStrictEqual(values);
+    });
+  });
+
+  describe('set()', () => {
+    it('only shows non-empty files', async () => {
+      const spy = jest.spyOn(editorMosaic, 'setVisibleMosaics');
+
+      const values = {
+        [DefaultEditorId.html]: 'html-value',
+        [DefaultEditorId.main]: 'main-value',
+        [DefaultEditorId.renderer]: 'renderer-value',
+        [DefaultEditorId.css]: '/* Empty */',
+        [DefaultEditorId.preload]: '',
+      } as const;
+
+      await editorMosaic.set(values);
+      expect(spy).toHaveBeenCalledWith([
+        DefaultEditorId.main,
+        DefaultEditorId.renderer,
+        DefaultEditorId.html,
+      ]);
+
+      spy.mockRestore();
+    });
+
+    it('shows visible mosaics for non-empty editor contents with custom mosaics', async () => {
+      const spy = jest.spyOn(editorMosaic, 'setVisibleMosaics');
+
+      const file = 'file.js';
+      const values = {
+        [DefaultEditorId.html]: 'html-value',
+        [DefaultEditorId.main]: 'main-value',
+        [DefaultEditorId.renderer]: 'renderer-value',
+        [DefaultEditorId.css]: '/* Empty */',
+        [DefaultEditorId.preload]: '',
+        [file]: 'file-value',
+      } as const;
+
+      await editorMosaic.set(values);
+      expect(spy).toHaveBeenCalledWith([
+        DefaultEditorId.main,
+        DefaultEditorId.renderer,
+        DefaultEditorId.html,
+        file,
+      ]);
+
+      spy.mockRestore();
+    });
+
+    it('sorts the mosaics', async () => {
+      const spy = jest.spyOn(editorMosaic, 'setVisibleMosaics');
+
+      // this order is defined inside the replaceFiddle() function
+      const values = createEditorValues();
+      await editorMosaic.set(values);
+      const expected = Object.keys(values).sort(compareEditors);
+      expect(spy).toHaveBeenCalledWith(expected);
+
+      spy.mockRestore();
+    });
+
+    it('attempts to set values', async () => {
+      const values: EditorValues = {
+        [DefaultEditorId.html]: 'html-value',
+        [DefaultEditorId.main]: 'main-value',
+        [DefaultEditorId.renderer]: 'renderer-value',
+      } as const;
+      for (const filename of Object.keys(values)) {
+        editorMosaic.editors.set(
+          filename as EditorId,
+          new MonacoEditorMock() as any,
+        );
+      }
+      await editorMosaic.set(values);
+
+      for (const [filename, value] of Object.entries(values)) {
+        const editor = editorMosaic.editors.get(filename as EditorId);
+        expect(editor!.setValue).toHaveBeenCalledWith(value);
+      }
+    });
+
+    it('attempts to set values for closed editors', async () => {
+      editorMosaic.editors.delete(DefaultEditorId.main);
+
+      (editorMosaic.closedPanels as any)[DefaultEditorId.main] = {
+        model: { setValue: jest.fn() },
+      };
+      editorMosaic.closedPanels[DefaultEditorId.preload] = {};
+      editorMosaic.closedPanels[DefaultEditorId.css] = {};
+
+      await editorMosaic.set({
+        [DefaultEditorId.html]: 'html-value',
+        [DefaultEditorId.main]: 'main-value',
+        [DefaultEditorId.renderer]: 'renderer-value',
+        [DefaultEditorId.preload]: 'preload-value',
+        [DefaultEditorId.css]: 'css-value',
+      });
+
+      expect(
+        (editorMosaic.closedPanels[DefaultEditorId.main] as EditorBackup)!
+          .model!.setValue,
+      ).toHaveBeenCalledWith('main-value');
+      expect(editorMosaic.closedPanels[DefaultEditorId.preload]).toEqual({
+        value: 'preload-value',
+      });
+      expect(editorMosaic.closedPanels[DefaultEditorId.css]).toEqual({
+        value: 'css-value',
+      });
+    });
+
+    it('does not set a value if none passed in', async () => {
+      const id = DefaultEditorId.renderer;
+      const oldValue = editorMosaic.getEditorValue(id);
+
+      await editorMosaic.set({
+        [DefaultEditorId.html]: 'html-value',
+        [DefaultEditorId.main]: 'main-value',
+      });
+
+      expect(editorMosaic.getEditorValue(id)).toBe(oldValue);
+    });
+  });
+
+  describe('getEditorValue()', () => {
+    const filename = DefaultEditorId.html;
+    const value = 'editor-value';
+
+    beforeEach(() => {
+      const editor = new MonacoEditorMock();
+      editor.getValue.mockReturnValue(value);
+      editorMosaic.editors.set(filename, editor as any);
+    });
+
+    it('returns the value for an editor if it exists', () => {
+      expect(editorMosaic.getEditorValue(filename)).toBe(value);
+    });
+
+    it('returns the value for the editor backup if it exists', () => {
+      // set up mock state that has the editor deleted and a backup
+      editorMosaic.editors.delete(filename);
+      const value = 'editor-backup-value';
+      editorMosaic.closedPanels = { [filename]: { value } };
+
+      expect(editorMosaic.getEditorValue(filename)).toBe(value);
+    });
+
+    it('returns an empty string if the editor does not exist', () => {
+      editorMosaic.editors.delete(filename);
+      expect(editorMosaic.getEditorValue(filename)).toBe('');
+    });
+  });
+
+  describe('getEditorBackup()', () => {
+    const filename = DefaultEditorId.html;
+    const value = 'editor-value';
+
+    beforeEach(() => {
+      const editor = new MonacoEditorMock();
+      editor.getValue.mockReturnValue(value);
+      editorMosaic.editors.set(filename, editor as any);
+    });
+
+    it('returns the value for an editor', () => {
+      const model = { testModel: true };
+      const viewState = { testViewState: true };
+      const value = 'editor-value';
+
+      const editor = editorMosaic.editors.get(filename) as any;
+      editor.model = model as any;
+      editor.saveViewState.mockReturnValue(viewState);
+      editor.value = value;
+
+      expect(editorMosaic.getEditorBackup(filename)).toEqual({
+        model,
+        value,
+        viewState,
+      });
+    });
+  });
+
+  describe('getEditorViewState()', () => {
+    const filename = DefaultEditorId.html;
+
+    it('returns the value for an editor', () => {
+      const editor = new MonacoEditorMock();
+      const viewState = { testViewState: true };
+      editor.saveViewState.mockReturnValue(viewState);
+      editorMosaic.editors.set(filename, editor as any);
+
+      expect(editorMosaic.getEditorViewState(filename)).toBe(viewState);
+    });
+
+    it('returns null if the editor does not exist', () => {
+      editorMosaic.editors.delete(filename);
+      expect(editorMosaic.getEditorViewState(filename)).toBeNull();
+    });
+  });
+
+  describe('getEditorModel()', () => {
+    const filename = DefaultEditorId.html;
+
+    beforeEach(() => {
+      ({ editorMosaic } = (window as any).ElectronFiddle.app.state);
+    });
+
+    it('returns the value for an editor', () => {
+      const model = { testModel: true };
+      const editor = new MonacoEditorMock();
+      editor.getModel.mockReturnValue(model);
+      editorMosaic.editors.set(filename, editor as any);
+
+      expect(editorMosaic.getEditorModel(filename)).toBe(model);
+    });
+
+    it('returns null if the editor does not exist', () => {
+      editorMosaic.editors.delete(filename);
+      expect(editorMosaic.getEditorModel(filename)).toBeNull();
+    });
+  });
+
+  describe('editor-layout', () => {
+    it('layout() calls editor.layout() only once', async () => {
+      const editor = new MonacoEditorMock();
+      const filename = DefaultEditorId.html;
+      await editorMosaic.editors.set(filename, editor as any);
+
+      editorMosaic.layout();
+      editorMosaic.layout();
+      editorMosaic.layout();
+      editorMosaic.layout();
+      await waitFor(() => editor.layout.mock.calls.length > 0);
+
+      expect(editor.layout).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('disposeLayoutAutorun()', () => {
+    it('automatically updates the layout when the mosaic arrangement changes', () => {
+      const spy = jest.spyOn(editorMosaic, 'layout');
+      editorMosaic.mosaicArrangement = DefaultEditorId.main;
+      expect(spy).toHaveBeenCalledTimes(1);
+      spy.mockRestore();
+    });
+  });
+});
diff --git a/tests/renderer/file-manager-spec.ts b/tests/renderer/file-manager-spec.ts
index 0eea7ac4..c28bc9fe 100644
--- a/tests/renderer/file-manager-spec.ts
+++ b/tests/renderer/file-manager-spec.ts
@@ -71,11 +71,11 @@ describe('FileManager', () => {
 
     it('does not do anything with incorrect inputs', async () => {
       await fm.openFiddle({} as any);
-      expect(app.setEditorValues).not.toHaveBeenCalled();
+      expect(app.replaceFiddle).not.toHaveBeenCalled();
     });
 
     it('does not do anything if cancelled', async () => {
-      (app.setEditorValues as jest.Mock).mockResolvedValueOnce(false);
+      (app.replaceFiddle as jest.Mock).mockResolvedValueOnce(false);
       await fm.openFiddle('/fake/path');
     });
   });
@@ -93,7 +93,7 @@ describe('FileManager', () => {
       const file = 'file.js';
       const content = 'hi';
       const values = { ...editorValues, [file]: content };
-      app.state.customMosaics = [file];
+      app.state.editorMosaic.customMosaics = [file];
       app.getEditorValues.mockReturnValueOnce(values);
 
       await fm.saveFiddle('/fake/path');
@@ -250,7 +250,7 @@ describe('FileManager', () => {
       const values = { ...editorValues, [file]: content };
       app.getEditorValues.mockReturnValue(values);
       expected.set(file, content);
-      app.state.customMosaics = [file];
+      app.state.editorMosaic.customMosaics = [file];
 
       expect(await fm.getFiles()).toStrictEqual(expected);
     });
diff --git a/tests/renderer/runner-spec.tsx b/tests/renderer/runner-spec.tsx
index faafef73..886e3c91 100644
--- a/tests/renderer/runner-spec.tsx
+++ b/tests/renderer/runner-spec.tsx
@@ -124,6 +124,7 @@ describe('Runner component', () => {
       expect(result).toBe(RunResult.SUCCESS);
       expect(store.isRunning).toBe(false);
       expect(store.pushOutput).toHaveBeenCalledTimes(8);
+      expect(store.flushOutput).toHaveBeenCalledTimes(1);
       expect(store.pushOutput).toHaveBeenLastCalledWith(
         'Electron exited with code 0.',
       );
@@ -145,6 +146,7 @@ describe('Runner component', () => {
 
       expect(result).toBe(RunResult.FAILURE);
       expect(store.isRunning).toBe(false);
+      expect(store.flushOutput).toHaveBeenCalledTimes(1);
       expect(store.pushOutput).toHaveBeenLastCalledWith(
         `Electron exited with code ${ARBITRARY_FAIL_CODE}.`,
       );
@@ -168,6 +170,7 @@ describe('Runner component', () => {
 
       expect(result).toBe(RunResult.INVALID);
       expect(store.isRunning).toBe(false);
+      expect(store.flushOutput).toHaveBeenCalledTimes(1);
       expect(store.pushOutput).toHaveBeenCalledTimes(8);
       expect(store.pushOutput).toHaveBeenLastCalledWith('Electron exited.');
     });
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 7fb86a4e..25f8c37f 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -1,6 +1,5 @@
 import {
   BlockableAccelerator,
-  DEFAULT_EDITORS,
   DefaultEditorId,
   ElectronReleaseChannel,
   GenericDialogType,
@@ -16,10 +15,6 @@ import {
   setupBinary,
 } from '../../src/renderer/binary';
 import { Bisector } from '../../src/renderer/bisect';
-import {
-  DEFAULT_MOSAIC_ARRANGEMENT,
-  SORTED_EDITORS,
-} from '../../src/renderer/constants';
 import { getTemplate, isContentUnchanged } from '../../src/renderer/content';
 import { ipcRendererManager } from '../../src/renderer/ipc';
 import { AppState } from '../../src/renderer/state';
@@ -28,7 +23,6 @@ import {
   getUpdatedElectronVersions,
   saveLocalVersions,
 } from '../../src/renderer/versions';
-import { createMosaicArrangement } from '../../src/utils/editors-mosaic-arrangement';
 import { waitFor } from '../../src/utils/wait-for';
 import { getName } from '../../src/utils/get-name';
 import { MonacoEditorMock, VersionsMock } from '../mocks/mocks';
@@ -600,108 +594,6 @@ describe('AppState', () => {
     });
   });
 
-  describe('getAndRemoveEditorValueBackup()', () => {
-    it('returns null if there is no backup', () => {
-      const result = appState.getAndRemoveEditorValueBackup(
-        DefaultEditorId.main,
-      );
-      expect(result).toEqual(undefined);
-    });
-
-    it('returns and deletes a backup if there is one', () => {
-      appState.closedPanels[DefaultEditorId.main] = { testBackup: true } as any;
-      const result = appState.getAndRemoveEditorValueBackup(
-        DefaultEditorId.main,
-      );
-      expect(result).toEqual({ testBackup: true });
-      expect(appState.closedPanels[DefaultEditorId.main]).toBeUndefined();
-    });
-  });
-
-  describe('setVisibleMosaics()', () => {
-    it('updates the visible editors and creates a backup', async () => {
-      appState.mosaicArrangement = createMosaicArrangement(DEFAULT_EDITORS);
-      appState.closedPanels = {};
-      appState.customMosaics = [];
-      await appState.setVisibleMosaics([DefaultEditorId.main]);
-
-      // we just need to mock something truthy here
-      const { editorMosaic } = (window as any).ElectronFiddle.app.state;
-      editorMosaic.editors.set(DefaultEditorId.main, {});
-
-      expect(appState.mosaicArrangement).toEqual(DefaultEditorId.main);
-      expect(appState.closedPanels[DefaultEditorId.renderer]).toBeTruthy();
-      expect(appState.closedPanels[DefaultEditorId.html]).toBeTruthy();
-      expect(appState.closedPanels[DefaultEditorId.main]).toBeUndefined();
-    });
-  });
-
-  describe('removeCustomMosaic()', () => {
-    it('removes a given custom mosaic', () => {
-      const file = 'file.js';
-      appState.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
-      appState.customMosaics = [file];
-
-      appState.removeCustomMosaic(file);
-
-      expect(appState.customMosaics).toEqual([]);
-    });
-  });
-
-  describe('hideAndBackupMosaic()', () => {
-    it('hides a given editor and creates a backup', () => {
-      appState.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
-      appState.closedPanels = {};
-      appState.hideAndBackupMosaic(SORTED_EDITORS[0]);
-
-      expect(appState.mosaicArrangement).toEqual({
-        direction: 'row',
-        first: SORTED_EDITORS[1],
-        second: {
-          direction: 'column',
-          first: SORTED_EDITORS[2],
-          second: SORTED_EDITORS[3],
-        },
-      });
-      expect(appState.closedPanels[DefaultEditorId.main]).toBeTruthy();
-      expect(appState.closedPanels[DefaultEditorId.renderer]).toBeUndefined();
-      expect(appState.closedPanels[DefaultEditorId.html]).toBeUndefined();
-    });
-  });
-
-  describe('showMosaic()', () => {
-    it('shows a given editor', () => {
-      appState.mosaicArrangement = DefaultEditorId.main;
-      appState.showMosaic(DefaultEditorId.html);
-
-      expect(appState.mosaicArrangement).toEqual({
-        direction: 'row',
-        first: DefaultEditorId.main,
-        second: DefaultEditorId.html,
-      });
-    });
-  });
-
-  describe('resetEditorLayout()', () => {
-    it('Puts editors in default arrangement', () => {
-      appState.hideAndBackupMosaic(SORTED_EDITORS[0]);
-
-      expect(appState.mosaicArrangement).toEqual({
-        direction: 'row',
-        first: SORTED_EDITORS[1],
-        second: {
-          direction: 'column',
-          first: SORTED_EDITORS[2],
-          second: SORTED_EDITORS[3],
-        },
-      });
-
-      appState.resetEditorLayout();
-
-      expect(appState.mosaicArrangement).toEqual(DEFAULT_MOSAIC_ARRANGEMENT);
-    });
-  });
-
   describe('blockAccelerators()', () => {
     it('adds an accelerator to be blocked', () => {
       appState.acceleratorsToBlock = [];
diff --git a/tests/utils.ts b/tests/utils.ts
index 81ea5bf7..827f50e8 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -1,3 +1,5 @@
+import { toJS } from 'mobx';
+
 const platform = process.platform;
 
 export function overridePlatform(value: NodeJS.Platform) {
@@ -51,3 +53,18 @@ export class FetchMock {
     });
   }
 }
+
+// return an object containing props in 'a' that are different from in 'b'
+export function objectDifference<Type>(a: Type, b: Type): Type {
+  const serialize = (input: any) => JSON.stringify(toJS(input));
+
+  const o = {};
+  for (const entry of Object.entries(a)) {
+    const key = entry[0];
+    const val = toJS(entry[1]);
+    if (serialize(val) == serialize(b[key])) continue;
+
+    o[key] = key === 'editorMosaic' ? objectDifference(val, b[key]) : toJS(val);
+  }
+  return o as Type;
+}
diff --git a/tests/utils/editor-backup-spec.ts b/tests/utils/editor-backup-spec.ts
deleted file mode 100644
index cec9c0dd..00000000
--- a/tests/utils/editor-backup-spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { DefaultEditorId } from '../../src/interfaces';
-import { getEditorBackup } from '../../src/utils/editor-backup';
-import { EditorMosaicMock } from '../mocks/mocks';
-
-describe('getEditorBackup()', () => {
-  let editorMosaic: EditorMosaicMock;
-
-  beforeEach(() => {
-    ({ editorMosaic } = (window as any).ElectronFiddle.app.state);
-  });
-
-  it('returns the value for an editor', () => {
-    const filename = DefaultEditorId.html;
-    const editor = editorMosaic.editors.get(filename);
-    const model = { testModel: true };
-    const viewState = { testViewState: true };
-    const value = 'editor-value';
-
-    (editor as any).model = model;
-    editor!.saveViewState.mockReturnValue(viewState);
-    editor!.value = value;
-
-    expect(getEditorBackup(filename)).toEqual({ model, value, viewState });
-  });
-});
diff --git a/tests/utils/editor-layout-spec.ts b/tests/utils/editor-layout-spec.ts
deleted file mode 100644
index d73b61db..00000000
--- a/tests/utils/editor-layout-spec.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { updateEditorLayout } from '../../src/utils/editor-layout';
-import { EditorMosaicMock } from '../mocks/mocks';
-
-describe('editor-layout', () => {
-  let editorMosaic: EditorMosaicMock;
-
-  beforeEach(() => {
-    ({ editorMosaic } = (window as any).ElectronFiddle.app.state);
-  });
-
-  it('updateEditorLayout calls layout() just once', (done) => {
-    updateEditorLayout();
-    updateEditorLayout();
-    updateEditorLayout();
-    updateEditorLayout();
-
-    setTimeout(() => {
-      for (const editor of editorMosaic.editors.values()) {
-        expect(editor.layout).toHaveBeenCalledTimes(1);
-      }
-      done();
-    }, 100);
-  });
-});
diff --git a/tests/utils/editor-model-spec.ts b/tests/utils/editor-model-spec.ts
deleted file mode 100644
index a45b40de..00000000
--- a/tests/utils/editor-model-spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { DefaultEditorId } from '../../src/interfaces';
-import { getEditorModel } from '../../src/utils/editor-model';
-import { EditorMosaicMock } from '../mocks/editor-mosaic';
-
-describe('getEditorModel()', () => {
-  const filename = DefaultEditorId.html;
-  let editorMosaic: EditorMosaicMock;
-
-  beforeEach(() => {
-    ({ editorMosaic } = (window as any).ElectronFiddle.app.state);
-  });
-
-  it('returns the value for an editor', () => {
-    const model = { testModel: true };
-    const editor = editorMosaic.editors.get(filename);
-    editor!.getModel.mockReturnValue(model);
-
-    expect(getEditorModel(filename)).toBe(model);
-  });
-
-  it('returns null if the editor does not exist', () => {
-    editorMosaic.editors.delete(filename);
-    expect(getEditorModel(filename)).toBeNull();
-  });
-
-  it('returns null if window.Fiddle is not ready', () => {
-    (window as any).ElectronFiddle = undefined;
-    expect(getEditorModel(filename)).toBeNull();
-  });
-});
diff --git a/tests/utils/editor-value-spec.ts b/tests/utils/editor-value-spec.ts
deleted file mode 100644
index f04aeb33..00000000
--- a/tests/utils/editor-value-spec.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { DefaultEditorId } from '../../src/interfaces';
-import { getEditorValue } from '../../src/utils/editor-value';
-import { AppMock, EditorMosaicMock, StateMock } from '../mocks/mocks';
-
-describe('getEditorValue()', () => {
-  const filename = DefaultEditorId.html;
-  const value = 'editor-value';
-  let app: AppMock;
-  let state: StateMock;
-  let editorMosaic: EditorMosaicMock;
-
-  beforeEach(() => {
-    ({ app } = (window as any).ElectronFiddle);
-    ({ state } = app);
-    ({ editorMosaic } = state);
-
-    for (const editor of editorMosaic.editors.values()) {
-      editor.getValue.mockReturnValue(value);
-    }
-  });
-
-  it('returns the value for an editor if it exists', () => {
-    expect(getEditorValue(filename)).toBe(value);
-  });
-
-  it('returns the value for the editor backup if it exists', () => {
-    // set up mock state that has the editor deleted and a backup
-    editorMosaic.editors.delete(filename);
-    const value = 'editor-backup-value';
-    state.closedPanels = { [filename]: { value } };
-
-    expect(getEditorValue(filename)).toBe(value);
-  });
-
-  it('returns an empty string if the editor does not exist', () => {
-    editorMosaic.editors.delete(filename);
-    expect(getEditorValue(filename)).toBe('');
-  });
-});
diff --git a/tests/utils/editor-viewstate-spec.ts b/tests/utils/editor-viewstate-spec.ts
deleted file mode 100644
index 61944909..00000000
--- a/tests/utils/editor-viewstate-spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { DefaultEditorId } from '../../src/interfaces';
-import { getEditorViewState } from '../../src/utils/editor-viewstate';
-import { EditorMosaicMock } from '../mocks/mocks';
-
-describe('getEditorViewState()', () => {
-  const filename = DefaultEditorId.html;
-  let editorMosaic: EditorMosaicMock;
-
-  beforeEach(() => {
-    ({ editorMosaic } = (window as any).ElectronFiddle.app.state);
-  });
-
-  it('returns the value for an editor', () => {
-    const viewState = { testViewState: true };
-    editorMosaic.editors
-      .get(filename)!
-      .saveViewState.mockReturnValue(viewState);
-    expect(getEditorViewState(filename)).toBe(viewState);
-  });
-
-  it('returns null if the editor does not exist', () => {
-    editorMosaic.editors.delete(filename);
-    expect(getEditorViewState(filename)).toEqual(null);
-  });
-
-  it('throws if window.Fiddle is not ready', () => {
-    (window as any).ElectronFiddle = undefined;
-    expect(getEditorViewState(filename)).toBeNull();
-  });
-});