Repository: manusa/electronim (js)

Issue:
Keyboard shortcut to switch to tab by position index
## Description

As a user I would like to switch to a specific tab (1-9) by using a keyboard shortcut to improve my productivity.

Pressing `Ctrl+[1-9]` allows me to switch to the tabs in the provided position.

Pull request:
✨ Ctrl+[1-9] / Cmd+[1-9] switches to tab at position
Fixes #182

Patch:
diff --git a/docs/Keyboard-shortcuts.md b/docs/Keyboard-shortcuts.md
index 800f220..53893c8 100644
--- a/docs/Keyboard-shortcuts.md
+++ b/docs/Keyboard-shortcuts.md
@@ -9,4 +9,5 @@
 | `Ctrl+0` `Cmd+0`                                           | Reset zoom.                    |
 | `Ctrl+Tab`                                                 | Jump to the next open tab.     |
 | `Ctrl+Shift+Tab`                                           | Jump to the previous open tab. |
+| `Ctrl+[1-9]` `Cmd+[1-9]`                                   | Jump to the tab at position #. |
 | `Esc`                                                      | (in dialog) Close.             |
diff --git a/src/browser-window/keyboard-shortcuts.js b/src/browser-window/keyboard-shortcuts.js
index 2544fc7..be20f5d 100644
--- a/src/browser-window/keyboard-shortcuts.js
+++ b/src/browser-window/keyboard-shortcuts.js
@@ -28,6 +28,13 @@ EVENTS.set(eventKey({key: 'Escape'}), () => {
 
 EVENTS.set(eventKey({key: 'F11'}), () => eventBus.emit(APP_EVENTS.fullscreenToggle));
 
+Array(9).fill(1).forEach((min, idx) => {
+  const key = min + idx;
+  const func = () => eventBus.emit(APP_EVENTS.tabSwitchToPosition, key);
+  EVENTS.set(eventKey({key, control: true}), func);
+  EVENTS.set(eventKey({key, meta: true}), func);
+});
+
 EVENTS.set(eventKey({key: 'Tab', control: true}), () =>
   eventBus.emit(APP_EVENTS.tabTraverseNext));
 
diff --git a/src/components/switch.mjs b/src/components/switch.mjs
index fa3e6bb..d8c9f47 100644
--- a/src/components/switch.mjs
+++ b/src/components/switch.mjs
@@ -15,6 +15,13 @@
  */
 import {html} from './index.mjs';
 
+/**
+ * A Switch based on Material design (3) guidelines.
+ *
+ * @param checked whether the switch is checked or not.
+ * @param onClick callback function to be executed when the switch is clicked.
+ * @param properties any other properties to be added to the switch.
+ */
 export const Switch = ({
   checked = false,
   onClick,
diff --git a/src/constants/index.js b/src/constants/index.js
index 906f95e..839315b 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -50,6 +50,7 @@ const APP_EVENTS = {
   canNotify: 'canNotify',
   tabsReady: 'tabsReady',
   tabReorder: 'tabReorder',
+  tabSwitchToPosition: 'tabSwitchToPosition',
   tabTraverseNext: 'tabTraverseNext',
   tabTraversePrevious: 'tabTraversePrevious',
   zoomIn: 'zoomIn',
diff --git a/src/main/index.js b/src/main/index.js
index d1d6b44..27926ff 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -102,6 +102,10 @@ const handleTabTraverse = getTabIdFunction => () => {
   activateTab(tabId);
 };
 
+const handleTabSwitchToPosition = tabPosition => {
+  handleTabTraverse(() => tabManager.getTabAt(tabPosition))();
+};
+
 const handleZoomIn = event => event.sender.setZoomFactor(event.sender.getZoomFactor() + 0.1);
 
 const handleZoomOut = event => {
@@ -213,6 +217,7 @@ const initGlobalListeners = () => {
   eventBus.handle(APP_EVENTS.settingsLoad, loadSettings);
   eventBus.on(APP_EVENTS.settingsOpenDialog, openSettingsDialog(mainWindow));
   eventBus.on(APP_EVENTS.settingsSave, saveSettings);
+  eventBus.on(APP_EVENTS.tabSwitchToPosition, handleTabSwitchToPosition);
   eventBus.on(APP_EVENTS.tabTraverseNext, handleTabTraverse(tabManager.getNextTab));
   eventBus.on(APP_EVENTS.tabTraversePrevious, handleTabTraverse(tabManager.getPreviousTab));
 };
diff --git a/src/tab-manager/index.js b/src/tab-manager/index.js
index 287bfc7..18904b3 100644
--- a/src/tab-manager/index.js
+++ b/src/tab-manager/index.js
@@ -137,6 +137,16 @@ const getTabTraverse = operation => () => {
 
 const getNextTab = getTabTraverse(idx => idx + 1);
 const getPreviousTab = getTabTraverse(idx => idx - 1);
+const getTabAt = position => {
+  const tabIds = Object.keys(tabs);
+  const idx = position - 1;
+  if (idx > 0 && idx < tabIds.length) {
+    return tabIds[idx];
+  } else if (idx < 1) {
+    return tabIds[0];
+  }
+  return tabIds[tabIds.length - 1];
+};
 
 const removeAll = () => {
   Object.values(tabs).forEach(browserView => browserView.webContents.destroy());
@@ -155,5 +165,5 @@ const canNotify = tabId => {
 };
 
 module.exports = {
-  addTabs, getTab, getActiveTab, setActiveTab, getNextTab, getPreviousTab, canNotify, reload, removeAll
+  addTabs, getTab, getTabAt, getActiveTab, setActiveTab, getNextTab, getPreviousTab, canNotify, reload, removeAll
 };


Test patch:
diff --git a/src/browser-window/__tests__/keyboard-shortcuts.test.js b/src/browser-window/__tests__/keyboard-shortcuts.test.js
index 8d8eb6c..5fe535a 100644
--- a/src/browser-window/__tests__/keyboard-shortcuts.test.js
+++ b/src/browser-window/__tests__/keyboard-shortcuts.test.js
@@ -38,6 +38,20 @@ describe('Main :: Global Keyboard Shortcuts module test suite', () => {
       browserWindow.listeners['before-input-event'](inputEvent, {key, control, shift});
       expect(electron.ipcMain.emit).toHaveBeenCalledWith(appEvent);
     });
+  describe.each([1, 2, 3, 4, 5, 6, 7, 8, 9])('Key "%s"', key => {
+    test('with ctrl, triggers "tabSwitchToPosition" app event', () => {
+      browserWindow.listeners['before-input-event'](inputEvent, {key, control: true});
+      expect(electron.ipcMain.emit).toHaveBeenCalledWith('tabSwitchToPosition', key);
+    });
+    test('with meta, triggers "tabSwitchToPosition" app event', () => {
+      browserWindow.listeners['before-input-event'](inputEvent, {key, meta: true});
+      expect(electron.ipcMain.emit).toHaveBeenCalledWith('tabSwitchToPosition', key);
+    });
+    test('with no modifiers, does nothing', () => {
+      browserWindow.listeners['before-input-event'](inputEvent, {key});
+      expect(electron.ipcMain.emit).not.toHaveBeenCalled();
+    });
+  });
   test('ignores keyUp events', () => {
     browserWindow.listeners['before-input-event'](inputEvent, {type: 'keyUp', key: 'Escape'});
     expect(electron.ipcMain.emit).not.toHaveBeenCalled();
diff --git a/src/main/__tests__/global-listeners.test.js b/src/main/__tests__/global-listeners.test.js
index 3b6786d..44c27eb 100644
--- a/src/main/__tests__/global-listeners.test.js
+++ b/src/main/__tests__/global-listeners.test.js
@@ -215,8 +215,7 @@ describe('Main :: Global listeners module test suite', () => {
         browserWindow.getBrowserViews = jest.fn(() => [new electron.BrowserView(), new electron.BrowserView()]);
       });
       test('tabTraverseNext', () => {
-        jest.spyOn(tabManagerModule, 'getNextTab')
-          .mockImplementation(() => 'nextTabId');
+        jest.spyOn(tabManagerModule, 'getNextTab').mockImplementation(() => 'nextTabId');
         main.init();
         // When
         eventBus.listeners.tabTraverseNext();
@@ -224,14 +223,21 @@ describe('Main :: Global listeners module test suite', () => {
         expect(tabManagerModule.getTab).toHaveBeenCalledWith('nextTabId');
       });
       test('tabTraversePrevious', () => {
-        jest.spyOn(tabManagerModule, 'getPreviousTab')
-          .mockImplementation(() => 'previousTabId');
+        jest.spyOn(tabManagerModule, 'getPreviousTab').mockImplementation(() => 'previousTabId');
         main.init();
         // When
         eventBus.listeners.tabTraversePrevious();
         // Then
         expect(tabManagerModule.getTab).toHaveBeenCalledWith('previousTabId');
       });
+      test('tabSwitchToPosition', () => {
+        jest.spyOn(tabManagerModule, 'getTabAt').mockImplementation(() => 'tabAtPosition');
+        main.init();
+        // When
+        eventBus.listeners.tabSwitchToPosition();
+        // Then
+        expect(tabManagerModule.getTab).toHaveBeenCalledWith('tabAtPosition');
+      });
     });
   });
 });
diff --git a/src/tab-manager/__tests__/index.test.js b/src/tab-manager/__tests__/index.test.js
index 302351c..d1fe767 100644
--- a/src/tab-manager/__tests__/index.test.js
+++ b/src/tab-manager/__tests__/index.test.js
@@ -59,7 +59,7 @@ describe('Tab Manager module test suite', () => {
       expect(result).toBeNull();
     });
   });
-  describe('getTabTraverse', () => {
+  describe('Tab traversal functions', () => {
     beforeEach(() => {
       tabManager.addTabs({send: jest.fn()})([
         {id: 'A'},
@@ -103,6 +103,26 @@ describe('Tab Manager module test suite', () => {
         expect(nextTab).toBe('C');
       });
     });
+    describe('getTabAt', () => {
+      test('with position in range, should return tab in range', () => {
+        // When
+        const nextTab = tabManager.getTabAt(2);
+        // Then
+        expect(nextTab).toBe('B');
+      });
+      test('with position out of range (upper), should return last', () => {
+        // When
+        const nextTab = tabManager.getTabAt(9);
+        // Then
+        expect(nextTab).toBe('C');
+      });
+      test('with position out of range (lower), should return last', () => {
+        // When
+        const nextTab = tabManager.getTabAt(-1);
+        // Then
+        expect(nextTab).toBe('A');
+      });
+    });
   });
   describe('addTabs', () => {
     test('webPreferences is sandboxed and has no node integration', () => {