Repository: electron/fiddle
Pull request #514 (2020-10-23 16:39:14)

feat: make save hotkey toggleable
This is my attempt to solve making `CmdOrCtrl+S` and `CmdOrCtrl+Shift+S` toggleable.


closes issue #406 


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 454bc813..244b4a1a 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -109,4 +109,5 @@ jobs:
             out/**/*setup*.exe
             out/**/*.nupkg
             out/**/*.rpm
-            out/**/*.zip
\ No newline at end of file
+            out/**/*.zip
+            out/**/RELEASES
diff --git a/.github/workflows/electron-releases.yml b/.github/workflows/electron-releases.yml
index 744f3f43..86a21545 100644
--- a/.github/workflows/electron-releases.yml
+++ b/.github/workflows/electron-releases.yml
@@ -12,17 +12,17 @@ jobs:
     - uses: actions/setup-node@v1
       with:
         node-version: '12.x'
-    - name: Get npm cache directory
-      id: npm-cache
-      run: |
-        echo "::set-output name=dir::$(npm config get cache)"
-    - uses: actions/cache@v1
+    - name: Get yarn cache directory path
+      id: yarn-cache-dir-path
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - uses: actions/cache@v2
+      id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
       with:
-        path: ${{ steps.npm-cache.outputs.dir }}
-        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
+        path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          ${{ runner.os }}-node-
-    - run: npm ci
+          ${{ runner.os }}-yarn-
+    - run: yarn
     - name: Switch to release update branch
       run: |
         if git branch --remotes | grep -q origin/update-releases; then
@@ -31,7 +31,7 @@ jobs:
           git checkout -b update-releases
         fi
     - name: Update Releases JSON
-      run: npm run electron-releases
+      run: yarn electron-releases
     - name: Commit Changes to Releases JSON
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -40,7 +40,7 @@ jobs:
         chmod 600 ~/.netrc
         git add static/releases.json tests/fixtures/releases-metadata.json
         if test -n "$(git status -s)"; then
-          git config user.name "$GITHUB_ACTOR"
+          git config user.name "Electron Bot"
           git config user.email "electron-bot@users.noreply.github.com"
           git diff --cached
           git commit -m "build: update Electron releases JSON"
diff --git a/package.json b/package.json
index 6fc63ee2..82604e4b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "electron-fiddle",
   "productName": "Electron Fiddle",
-  "version": "0.17.1",
+  "version": "0.17.2",
   "description": "The easiest way to get started with Electron",
   "repository": "https://github.com/electron/fiddle",
   "main": "./dist/src/main/main",
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 07eb49a6..25f1c229 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -118,3 +118,8 @@ export const enum DocsDemoPage {
   DEFAULT = 'DEFAULT',
   DEMO_APP = 'DEMO_APP',
 }
+
+export const enum BlockableAccelerator {
+  save = 'save',
+  saveAs = 'saveAs',
+}
diff --git a/src/ipc-events.ts b/src/ipc-events.ts
index 976bcc64..c7e0166b 100644
--- a/src/ipc-events.ts
+++ b/src/ipc-events.ts
@@ -28,6 +28,7 @@ export enum IpcEvents {
   CONFIRM_QUIT = 'CONFIRM_QUIT',
   SET_APPDATA_DIR = 'SET_APPDATA_DIR',
   SELECT_ALL_IN_EDITOR = 'SELECT_ALL_IN_EDITOR',
+  BLOCK_ACCELERATORS = 'BLOCK_ACCELERATORS',
 }
 
 export const ipcMainEvents = [
@@ -38,6 +39,7 @@ export const ipcMainEvents = [
   IpcEvents.SHOW_LOCAL_VERSION_FOLDER_DIALOG,
   IpcEvents.SHOW_INACTIVE,
   IpcEvents.CONFIRM_QUIT,
+  IpcEvents.BLOCK_ACCELERATORS,
 ];
 
 export const ipcRendererEvents = [
diff --git a/src/main/main.ts b/src/main/main.ts
index 6340aeb3..457d669c 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -30,6 +30,7 @@ export async function onReady() {
   const { setupFileListeners } = await import('./files');
 
   setupMenu();
+  setupMenuHandler();
   setupProtocolHandler();
   setupFileListeners();
   setupUpdates();
@@ -56,6 +57,15 @@ export function quitAppIfConfirmed(
   }
 }
 
+export function setupMenuHandler() {
+  ipcMainManager.on(
+    IpcEvents.BLOCK_ACCELERATORS,
+    async (_, acceleratorsToBlock) => {
+      (await import('./menu')).setupMenu(acceleratorsToBlock);
+    },
+  );
+}
+
 /**
  * All windows have been closed, quit on anything but
  * macOS.
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 9c5084b8..39c58cda 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -6,7 +6,7 @@ import {
   shell,
 } from 'electron';
 
-import { Templates } from '../interfaces';
+import { BlockableAccelerator, Templates } from '../interfaces';
 import { IpcEvents } from '../ipc-events';
 import { SHOW_ME_TEMPLATES } from '../templates';
 import { showOpenDialog, showSaveDialog } from './files';
@@ -182,7 +182,9 @@ function getShowMeMenu(): MenuItemConstructorOptions {
  *
  * @returns {Array<Electron.MenuItemConstructorOptions>}
  */
-function getFileMenu(): MenuItemConstructorOptions {
+function getFileMenu(
+  acceleratorsToBlock: BlockableAccelerator[] = [],
+): MenuItemConstructorOptions {
   const fileMenu: Array<MenuItemConstructorOptions> = [
     {
       label: 'New Fiddle',
@@ -208,12 +210,16 @@ function getFileMenu(): MenuItemConstructorOptions {
     {
       label: 'Save',
       click: () => ipcMainManager.send(IpcEvents.FS_SAVE_FIDDLE),
-      accelerator: 'CmdOrCtrl+S',
+      accelerator: !acceleratorsToBlock.includes(BlockableAccelerator.save)
+        ? 'CmdOrCtrl+S'
+        : undefined,
     },
     {
       label: 'Save as',
       click: () => showSaveDialog(IpcEvents.FS_SAVE_FIDDLE),
-      accelerator: 'CmdOrCtrl+Shift+S',
+      accelerator: !acceleratorsToBlock.includes(BlockableAccelerator.saveAs)
+        ? 'CmdOrCtrl+Shift+S'
+        : undefined,
     },
     {
       type: 'separator',
@@ -248,7 +254,7 @@ function getFileMenu(): MenuItemConstructorOptions {
 /**
  * Creates the app's window menu.
  */
-export function setupMenu() {
+export function setupMenu(acceleratorsToBlock: BlockableAccelerator[] = []) {
   // Get template for default menu
   const defaultMenu = require('electron-default-menu');
   const menu = (defaultMenu(app, shell) as Array<
@@ -329,7 +335,11 @@ export function setupMenu() {
     return item;
   });
 
-  menu.splice(process.platform === 'darwin' ? 1 : 0, 0, getFileMenu());
+  menu.splice(
+    process.platform === 'darwin' ? 1 : 0,
+    0,
+    getFileMenu(acceleratorsToBlock),
+  );
 
   menu.splice(menu.length - 1, 0, getTasksMenu(), getShowMeMenu());
 
diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx
index 2b9616e3..0eecff80 100644
--- a/src/renderer/components/commands-action-button.tsx
+++ b/src/renderer/components/commands-action-button.tsx
@@ -376,8 +376,9 @@ export class GistActionButton extends React.Component<
 
   private renderPrivacyMenu = () => {
     const { gitHubPublishAsPublic, gistId } = this.props.appState;
+    const { actionType } = this.state;
 
-    if (gistId) {
+    if (gistId && actionType !== GistActionType.publish) {
       return null;
     }
 
diff --git a/src/renderer/components/settings-general-block-accelerators.tsx b/src/renderer/components/settings-general-block-accelerators.tsx
new file mode 100644
index 00000000..05a360d7
--- /dev/null
+++ b/src/renderer/components/settings-general-block-accelerators.tsx
@@ -0,0 +1,79 @@
+import { Callout, Checkbox, FormGroup } from '@blueprintjs/core';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { BlockableAccelerator } from '../../interfaces';
+
+import { AppState } from '../state';
+
+export interface BlockAcceleratorsSettingsProps {
+  appState: AppState;
+}
+
+/**
+ * Settings content to block keyboard accelerators.
+ *
+ * @class BlockAcceleratorsSettings
+ * @extends {React.Component<BlockAcceleratorsSettingsProps>}
+ */
+@observer
+export class BlockAcceleratorsSettings extends React.Component<
+  BlockAcceleratorsSettingsProps
+> {
+  constructor(props: BlockAcceleratorsSettingsProps) {
+    super(props);
+
+    this.handleBlockAcceleratorChange = this.handleBlockAcceleratorChange.bind(
+      this,
+    );
+  }
+
+  /**
+   * Handles a change on whether a keyboard shortcut should be blocked
+   * before fiddle is executed.
+   *
+   * @param {React.ChangeEvent<HTMLInputElement>} event
+   */
+  public handleBlockAcceleratorChange(
+    event: React.FormEvent<HTMLInputElement>,
+  ) {
+    const { checked, value } = event.currentTarget;
+    if (checked) {
+      this.props.appState.addAcceleratorToBlock(value as BlockableAccelerator);
+    } else {
+      this.props.appState.removeAcceleratorToBlock(
+        value as BlockableAccelerator,
+      );
+    }
+  }
+
+  public render() {
+    const { acceleratorsToBlock } = this.props.appState;
+
+    const blockAcceleratorsLabel = `
+      Any keyboard shortcuts checked below will be disabled.`.trim();
+
+    return (
+      <div>
+        <h4>Block Keyboard Shortcuts</h4>
+        <Callout>
+          <FormGroup label={blockAcceleratorsLabel}>
+            <Checkbox
+              checked={acceleratorsToBlock.includes(BlockableAccelerator.save)}
+              label="Save"
+              value={BlockableAccelerator.save}
+              onChange={this.handleBlockAcceleratorChange}
+            />
+            <Checkbox
+              checked={acceleratorsToBlock.includes(
+                BlockableAccelerator.saveAs,
+              )}
+              label="Save as"
+              value={BlockableAccelerator.saveAs}
+              onChange={this.handleBlockAcceleratorChange}
+            />
+          </FormGroup>
+        </Callout>
+      </div>
+    );
+  }
+}
diff --git a/src/renderer/components/settings-general.tsx b/src/renderer/components/settings-general.tsx
index a90cd4ab..a6cf5781 100644
--- a/src/renderer/components/settings-general.tsx
+++ b/src/renderer/components/settings-general.tsx
@@ -4,6 +4,7 @@ import * as React from 'react';
 
 import { AppState } from '../state';
 import { AppearanceSettings } from './settings-general-appearance';
+import { BlockAcceleratorsSettings } from './settings-general-block-accelerators';
 import { ConsoleSettings } from './settings-general-console';
 import { GitHubSettings } from './settings-general-github';
 
@@ -32,6 +33,8 @@ export class GeneralSettings extends React.Component<GeneralSettingsProps> {
         <ConsoleSettings appState={this.props.appState} />
         <Divider />
         <GitHubSettings appState={this.props.appState} />
+        <Divider />
+        <BlockAcceleratorsSettings appState={this.props.appState} />
       </div>
     );
   }
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index bbcc7a93..c436f467 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -4,6 +4,7 @@ import { MosaicNode } from 'react-mosaic-component';
 
 import {
   ALL_MOSAICS,
+  BlockableAccelerator,
   DocsDemoPage,
   EditorId,
   GenericDialogOptions,
@@ -128,7 +129,8 @@ export class AppState {
       : (this.retrieve('executionFlags') as Array<string>);
   @observable public packageManager: IPackageManager =
     (localStorage.getItem('packageManager') as IPackageManager) || 'npm';
-
+  @observable public acceleratorsToBlock: Array<BlockableAccelerator> =
+    (this.retrieve('acceleratorsToBlock') as Array<BlockableAccelerator>) || [];
   // -- Various session-only state ------------------
   @observable public gistId: string | undefined;
   @observable public versions: Record<
@@ -199,6 +201,8 @@ export class AppState {
     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);
 
     ipcRendererManager.removeAllListeners(IpcEvents.OPEN_SETTINGS);
     ipcRendererManager.removeAllListeners(IpcEvents.SHOW_WELCOME_TOUR);
@@ -240,6 +244,7 @@ export class AppState {
     autorun(() => this.save('channelsToShow', this.channelsToShow));
     autorun(() => this.save('statesToShow', this.statesToShow));
     autorun(() => this.save('packageManager', this.packageManager ?? 'npm'));
+    autorun(() => this.save('acceleratorsToBlock', this.acceleratorsToBlock));
 
     autorun(() => {
       if (typeof this.isUnsaved === 'undefined') return;
@@ -294,6 +299,11 @@ export class AppState {
 
     // Make sure the console isn't all empty and sad
     this.pushOutput('Console ready 🔬');
+
+    // set blocked shortcuts
+    ipcRendererManager.send(IpcEvents.BLOCK_ACCELERATORS, [
+      ...this.acceleratorsToBlock,
+    ]);
   }
 
   /**
@@ -756,6 +766,25 @@ export class AppState {
     this.mosaicArrangement = DEFAULT_MOSAIC_ARRANGEMENT;
   }
 
+  @action public async addAcceleratorToBlock(acc: BlockableAccelerator) {
+    if (!this.acceleratorsToBlock.includes(acc)) {
+      this.acceleratorsToBlock = [...this.acceleratorsToBlock, acc];
+      ipcRendererManager.send(IpcEvents.BLOCK_ACCELERATORS, [
+        ...this.acceleratorsToBlock,
+      ]);
+    }
+  }
+
+  @action public async removeAcceleratorToBlock(acc: BlockableAccelerator) {
+    if (this.acceleratorsToBlock.includes(acc)) {
+      this.acceleratorsToBlock = this.acceleratorsToBlock.filter(
+        (a) => a !== acc,
+      );
+      ipcRendererManager.send(IpcEvents.BLOCK_ACCELERATORS, [
+        ...this.acceleratorsToBlock,
+      ]);
+    }
+  }
   /**
    * Resets the view, optionally with certain view flags enabled.
    *


Test patch:
diff --git a/tests/main/main-spec.ts b/tests/main/main-spec.ts
index ebfd22d1..bc4427ae 100644
--- a/tests/main/main-spec.ts
+++ b/tests/main/main-spec.ts
@@ -12,6 +12,7 @@ import {
   onReady,
   onWindowsAllClosed,
   quitAppIfConfirmed,
+  setupMenuHandler,
 } from '../../src/main/main';
 import { shouldQuit } from '../../src/main/squirrel';
 import { setupUpdates } from '../../src/main/update';
@@ -121,4 +122,15 @@ describe('main', () => {
       expect(app.quit).toHaveBeenCalledTimes(0);
     });
   });
+
+  describe('setupMenuHandler()', () => {
+    it('check if listening on BLOCK_ACCELERATORS', () => {
+      setupMenuHandler();
+
+      expect(ipcMainManager.on).toHaveBeenCalledWith(
+        IpcEvents.BLOCK_ACCELERATORS,
+        expect.anything(),
+      );
+    });
+  });
 });
diff --git a/tests/main/menu-spec.ts b/tests/main/menu-spec.ts
index 0427b10a..bfcc5521 100644
--- a/tests/main/menu-spec.ts
+++ b/tests/main/menu-spec.ts
@@ -3,6 +3,7 @@
  */
 
 import * as electron from 'electron';
+import { BlockableAccelerator } from '../../src/interfaces';
 
 import { IpcEvents } from '../../src/ipc-events';
 import { ipcMainManager } from '../../src/main/ipc';
@@ -320,6 +321,20 @@ describe('menu', () => {
         file.submenu[9].click();
         expect(electron.dialog.showOpenDialog).toHaveBeenCalled();
       });
+
+      it('saves a Fiddle with blocked accelerator', () => {
+        setupMenu([BlockableAccelerator.save]);
+        file.submenu[5].click();
+        expect(ipcMainManager.send).toHaveBeenCalledWith(
+          IpcEvents.FS_SAVE_FIDDLE,
+        );
+      });
+
+      it('saves as a Fiddle with blocked accelerator', () => {
+        setupMenu([BlockableAccelerator.saveAs]);
+        file.submenu[6].click();
+        expect(electron.dialog.showOpenDialog).toHaveBeenCalled();
+      });
     });
   });
 });
diff --git a/tests/renderer/components/__snapshots__/settings-general-spec.tsx.snap b/tests/renderer/components/__snapshots__/settings-general-spec.tsx.snap
index 378a9f30..9ce2d340 100644
--- a/tests/renderer/components/__snapshots__/settings-general-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/settings-general-spec.tsx.snap
@@ -17,5 +17,9 @@ exports[`GeneralSettings component renders 1`] = `
   <settings-github
     appState={Object {}}
   />
+  <Blueprint3.Divider />
+  <settings-block-accelerators
+    appState={Object {}}
+  />
 </div>
 `;
diff --git a/tests/renderer/components/settings-general-block-accelerators.tsx b/tests/renderer/components/settings-general-block-accelerators.tsx
new file mode 100644
index 00000000..30bc5c4d
--- /dev/null
+++ b/tests/renderer/components/settings-general-block-accelerators.tsx
@@ -0,0 +1,36 @@
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { BlockableAccelerator } from '../../../src/interfaces';
+
+import { BlockAcceleratorsSettings } from '../../../src/renderer/components/settings-general-block-accelerators';
+
+describe('BlockAcceleratorsSettings component', () => {
+  let store: any;
+
+  beforeEach(() => {
+    store = {};
+  });
+
+  it('renders', () => {
+    const wrapper = shallow(<BlockAcceleratorsSettings appState={store} />);
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  describe('handleBlockAcceleratorChange()', () => {
+    it('handles a new selection', async () => {
+      const wrapper = shallow(<BlockAcceleratorsSettings appState={store} />);
+      const instance = wrapper.instance() as any;
+      await instance.handleBlockAcceleratorChange({
+        currentTarget: { checked: false, value: BlockableAccelerator.save },
+      });
+
+      expect(store.acceleratorsToBlock).toBe([]);
+
+      await instance.handleBlockAcceleratorChange({
+        currentTarget: { checked: false, value: BlockableAccelerator.save },
+      });
+
+      expect(store.acceleratorsToBlock).toBe([BlockableAccelerator.save]);
+    });
+  });
+});
diff --git a/tests/renderer/components/settings-general-spec.tsx b/tests/renderer/components/settings-general-spec.tsx
index 7b95fd21..56a0e9c5 100644
--- a/tests/renderer/components/settings-general-spec.tsx
+++ b/tests/renderer/components/settings-general-spec.tsx
@@ -22,6 +22,13 @@ jest.mock(
   }),
 );
 
+jest.mock(
+  '../../../src/renderer/components/settings-general-block-accelerators',
+  () => ({
+    BlockAcceleratorsSettings: 'settings-block-accelerators',
+  }),
+);
+
 describe('GeneralSettings component', () => {
   const store: any = {};
 
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 97fdd7a5..c49c0f34 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -2,6 +2,7 @@ import * as MonacoType from 'monaco-editor';
 
 import {
   ALL_MOSAICS,
+  BlockableAccelerator,
   EditorId,
   GenericDialogType,
   PanelId,
@@ -638,4 +639,20 @@ describe('AppState', () => {
       expect(appState.mosaicArrangement).toEqual(DEFAULT_MOSAIC_ARRANGEMENT);
     });
   });
+
+  describe('blockAccelerators()', () => {
+    it('adds an accelerator to be blocked', () => {
+      appState.addAcceleratorToBlock(BlockableAccelerator.save);
+
+      expect(appState.acceleratorsToBlock).toEqual([BlockableAccelerator.save]);
+    });
+
+    it('removes an accelerator to be blocked', () => {
+      appState.acceleratorsToBlock = [BlockableAccelerator.save];
+
+      appState.removeAcceleratorToBlock(BlockableAccelerator.save);
+
+      expect(appState.acceleratorsToBlock).toEqual([]);
+    });
+  });
 });