diff --git a/apps/desktop/src/components/hotkeys/hotkeys.test.ts b/apps/desktop/src/components/hotkeys/hotkeys.test.ts new file mode 100644 index 00000000..714a06be --- /dev/null +++ b/apps/desktop/src/components/hotkeys/hotkeys.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import { getTabIdForNumberShortcut } from "./hotkeys" + +describe("getTabIdForNumberShortcut", () => { + it("selects the first, second, and ninth tabs by number", () => { + const tabs = Array.from({ length: 9 }, (_, index) => ({ + id: index + 101, + })) + + expect(getTabIdForNumberShortcut(tabs, 1)).toBe(101) + expect(getTabIdForNumberShortcut(tabs, 2)).toBe(102) + expect(getTabIdForNumberShortcut(tabs, 9)).toBe(109) + }) + + it("returns null when the requested tab number does not exist", () => { + const tabs = [{ id: 11 }, { id: 22 }, { id: 33 }] + + expect(getTabIdForNumberShortcut(tabs, 4)).toBeNull() + expect(getTabIdForNumberShortcut(tabs, 9)).toBeNull() + }) + + it("maps shortcut 9 to the ninth tab instead of the last tab", () => { + const tabs = Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + })) + + expect(getTabIdForNumberShortcut(tabs, 9)).toBe(9) + }) +}) diff --git a/apps/desktop/src/components/hotkeys/hotkeys.tsx b/apps/desktop/src/components/hotkeys/hotkeys.tsx index 4d8afff3..d8748b2c 100644 --- a/apps/desktop/src/components/hotkeys/hotkeys.tsx +++ b/apps/desktop/src/components/hotkeys/hotkeys.tsx @@ -1,6 +1,7 @@ import { APP_HOTKEY_DEFINITIONS, type AppHotkeyActionId, + FIXED_TAB_SHORTCUT_DIGITS, } from "@mdit/store/hotkeys" import { useHotkey } from "@tanstack/react-hotkeys" import { useCallback, useMemo } from "react" @@ -9,7 +10,6 @@ import { closeTabOrHideWindow } from "@/lib/close-tab-or-hide-window" import { useStore } from "@/store" const HOTKEY_OPTIONS = { preventDefault: true } as const - type HotkeyBindingProps = { binding: string onTrigger: () => void @@ -24,6 +24,17 @@ function HotkeyBinding({ binding, onTrigger }: HotkeyBindingProps) { return null } +export function getTabIdForNumberShortcut( + tabs: readonly T[], + digit: number, +): number | null { + if (!Number.isInteger(digit) || digit < 1 || digit > 9) { + return null + } + + return tabs[digit - 1]?.id ?? null +} + export function Hotkeys() { const { hotkeys, @@ -32,6 +43,7 @@ export function Hotkeys() { isEditMode, closeActiveTab, openFolderPicker, + activateTabById, activatePreviousTab, activateNextTab, workspacePath, @@ -57,6 +69,7 @@ export function Hotkeys() { isEditMode: s.isEditMode, closeActiveTab: s.closeActiveTab, openFolderPicker: s.openFolderPicker, + activateTabById: s.activateTabById, activatePreviousTab: s.activatePreviousTab, activateNextTab: s.activateNextTab, workspacePath: s.workspacePath, @@ -85,6 +98,21 @@ export function Hotkeys() { }) }, [activeTabId, closeActiveTab, isEditMode]) + const handleActivateTabByNumber = useCallback( + (digit: number) => { + const targetTabId = getTabIdForNumberShortcut( + useStore.getState().tabs, + digit, + ) + if (targetTabId === null) { + return + } + + activateTabById(targetTabId) + }, + [activateTabById], + ) + const actionHandlers = useMemo void>>( () => ({ "create-note": () => { @@ -183,6 +211,13 @@ export function Hotkeys() { /> ) })} + {FIXED_TAB_SHORTCUT_DIGITS.map((digit) => ( + handleActivateTabByNumber(digit)} + /> + ))} ) } diff --git a/packages/store/src/core.ts b/packages/store/src/core.ts index e0c38ac7..5d388799 100644 --- a/packages/store/src/core.ts +++ b/packages/store/src/core.ts @@ -40,10 +40,12 @@ export { APP_HOTKEY_CATEGORY_LABELS, APP_HOTKEY_DEFINITIONS, createDefaultAppHotkeys, + FIXED_TAB_SHORTCUT_DIGITS, findHotkeyConflict, hotkeyToDisplayTokens, hotkeyToMenuAccelerator, isAppHotkeyActionId, + isReservedAppHotkeyBinding, mergeWithDefaultHotkeys, normalizeHotkeyBinding, } from "./hotkeys/hotkey-utils" diff --git a/packages/store/src/hotkeys/hotkey-utils.ts b/packages/store/src/hotkeys/hotkey-utils.ts index 30ce623f..55d1b9fd 100644 --- a/packages/store/src/hotkeys/hotkey-utils.ts +++ b/packages/store/src/hotkeys/hotkey-utils.ts @@ -30,6 +30,8 @@ export type AppHotkeyDefinition = { export type AppHotkeyMap = Record +export const FIXED_TAB_SHORTCUT_DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const + export const APP_HOTKEY_DEFINITIONS: readonly AppHotkeyDefinition[] = [ { id: "create-note", @@ -252,6 +254,17 @@ export function findHotkeyConflict( return null } +export function isReservedAppHotkeyBinding(binding: string): boolean { + const normalizedBinding = normalizeHotkeyBinding(binding) + if (!normalizedBinding) { + return false + } + + return FIXED_TAB_SHORTCUT_DIGITS.some( + (digit) => normalizedBinding === `Mod+${digit}`, + ) +} + export function hotkeyToMenuAccelerator(binding: string): string | undefined { const normalizedBinding = normalizeHotkeyBinding(binding) if (!normalizedBinding) { diff --git a/packages/store/src/hotkeys/hotkeys-slice.test.ts b/packages/store/src/hotkeys/hotkeys-slice.test.ts index d5bdfa9c..5f3afefd 100644 --- a/packages/store/src/hotkeys/hotkeys-slice.test.ts +++ b/packages/store/src/hotkeys/hotkeys-slice.test.ts @@ -81,6 +81,18 @@ describe("hotkeys-slice", () => { expect(storage.save).not.toHaveBeenCalled() }) + it("rejects fixed tab shortcut bindings", async () => { + const store = createHotkeysStore(storage) + + const result = await store + .getState() + .setHotkeyBinding("create-note", "mod+1") + + expect(result.success).toBe(false) + expect(result.error).toBe("Shortcut is reserved for tab switching") + expect(storage.save).not.toHaveBeenCalled() + }) + it("allows unassigned hotkeys", async () => { const store = createHotkeysStore(storage) diff --git a/packages/store/src/hotkeys/hotkeys-slice.ts b/packages/store/src/hotkeys/hotkeys-slice.ts index d79b2765..8c37f213 100644 --- a/packages/store/src/hotkeys/hotkeys-slice.ts +++ b/packages/store/src/hotkeys/hotkeys-slice.ts @@ -5,6 +5,7 @@ import { type AppHotkeyMap, createDefaultAppHotkeys, findHotkeyConflict, + isReservedAppHotkeyBinding, normalizeHotkeyBinding, } from "./hotkey-utils" @@ -74,6 +75,13 @@ export const prepareHotkeysSlice = error: validationResult.errors[0] ?? "Invalid hotkey format", } } + + if (isReservedAppHotkeyBinding(normalizedBinding)) { + return { + success: false, + error: "Shortcut is reserved for tab switching", + } + } } const currentHotkeys = get().hotkeys