diff --git a/apps/desktop/src/renderer/hotkeys/migrate.test.ts b/apps/desktop/src/renderer/hotkeys/migrate.test.ts new file mode 100644 index 00000000000..cfdbc27b12e --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/migrate.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +let oldStateResolver: () => unknown = () => null; +let queryCallCount = 0; + +mock.module("renderer/lib/trpc-client", () => ({ + electronTrpcClient: { + uiState: { + hotkeys: { + get: { + query: async () => { + queryCallCount++; + return oldStateResolver(); + }, + }, + }, + }, + }, +})); + +mock.module("./registry", () => ({ + PLATFORM: "mac" as const, +})); + +const storage = new Map(); +// biome-ignore lint/suspicious/noExplicitAny: test polyfill +(globalThis as any).localStorage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => storage.clear(), +}; + +const { migrateHotkeyOverrides } = await import("./migrate"); + +const STORE_KEY = "hotkey-overrides"; + +describe("migrateHotkeyOverrides", () => { + const originalLog = console.log; + + beforeEach(() => { + storage.clear(); + queryCallCount = 0; + oldStateResolver = () => null; + console.log = mock(() => undefined); + }); + + afterEach(() => { + console.log = originalLog; + }); + + it("skips when the migrated sentinel is already set", async () => { + storage.set("hotkey-overrides-migrated", "1"); + + await migrateHotkeyOverrides(); + + expect(queryCallCount).toBe(0); + }); + + it("skips when the old state has no overrides for the current platform", async () => { + oldStateResolver = () => ({ + version: 1, + byPlatform: { darwin: {}, win32: {}, linux: {} }, + }); + + await migrateHotkeyOverrides(); + + expect(queryCallCount).toBe(1); + expect(storage.get(STORE_KEY)).toBeUndefined(); + }); + + it("writes old overrides into the new store format", async () => { + oldStateResolver = () => ({ + version: 1, + byPlatform: { + darwin: { + NEW_GROUP: "meta+shift+t", + CLOSE_PANE: null, + }, + win32: { NEW_GROUP: "ctrl+shift+t" }, + linux: {}, + }, + }); + + await migrateHotkeyOverrides(); + + const written = JSON.parse(storage.get(STORE_KEY) ?? "null"); + expect(written).toEqual({ + state: { + overrides: { + NEW_GROUP: "meta+shift+t", + CLOSE_PANE: null, + }, + }, + version: 0, + }); + }); + + it("swallows tRPC errors without throwing or writing", async () => { + oldStateResolver = () => { + throw new Error("boom"); + }; + + await migrateHotkeyOverrides(); + + expect(storage.get(STORE_KEY)).toBeUndefined(); + }); + + it("runs the tRPC query exactly once even when there are no overrides", async () => { + oldStateResolver = () => ({ + version: 1, + byPlatform: { darwin: {}, win32: {}, linux: {} }, + }); + + await migrateHotkeyOverrides(); + await migrateHotkeyOverrides(); + await migrateHotkeyOverrides(); + + expect(queryCallCount).toBe(1); + }); + + it("translates punctuation key-strings to the new registry's code-based names", async () => { + oldStateResolver = () => ({ + version: 1, + byPlatform: { + darwin: { + NAVIGATE_BACK: "meta+[", + NAVIGATE_FORWARD: "meta+]", + OPEN_SETTINGS: "meta+,", + OPEN_SHORTCUTS: "meta+shift+/", + }, + win32: {}, + linux: {}, + }, + }); + + await migrateHotkeyOverrides(); + + const written = JSON.parse(storage.get(STORE_KEY) ?? "null"); + expect(written.state.overrides).toEqual({ + NAVIGATE_BACK: "meta+bracketleft", + NAVIGATE_FORWARD: "meta+bracketright", + OPEN_SETTINGS: "meta+comma", + OPEN_SHORTCUTS: "meta+shift+slash", + }); + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/migrate.ts b/apps/desktop/src/renderer/hotkeys/migrate.ts index 8712ce32ad7..adb8116c0f1 100644 --- a/apps/desktop/src/renderer/hotkeys/migrate.ts +++ b/apps/desktop/src/renderer/hotkeys/migrate.ts @@ -1,8 +1,6 @@ /** * One-time migration from the old hotkey storage (main process JSON file via tRPC) * to the new localStorage-based Zustand store. - * - * No-op if the new store key already exists (Zustand persist creates it on first init). */ import { electronTrpcClient } from "renderer/lib/trpc-client"; @@ -14,29 +12,60 @@ const PLATFORM_MAP = { linux: "linux", } as const; +const STORE_KEY = "hotkey-overrides"; +const MIGRATED_FLAG = "hotkey-overrides-migrated"; + +// The new registry uses code-based names for punctuation (e.g. "bracketleft") +// because react-hotkeys-hook matches on KeyboardEvent.code. The old registry +// stored the literal character ("["). Translate token-by-token so a user's +// "meta+[" override lands as "meta+bracketleft" in the new store. +const PUNCT_TO_CODE: Record = { + "[": "bracketleft", + "]": "bracketright", + ",": "comma", + ".": "period", + "/": "slash", + "\\": "backslash", + ";": "semicolon", + "'": "quote", + "`": "backquote", + "-": "minus", + "=": "equal", +}; + +function translateKeyString(keys: string): string { + return keys + .split("+") + .map((token) => PUNCT_TO_CODE[token] ?? token) + .join("+"); +} + export async function migrateHotkeyOverrides(): Promise { - if (localStorage.getItem("hotkey-overrides")) { - console.log("[hotkeys] Migration skipped — new store already exists"); - return; - } + if (localStorage.getItem(MIGRATED_FLAG)) return; try { const oldState = await electronTrpcClient.uiState.hotkeys.get.query(); const oldPlatformKey = PLATFORM_MAP[PLATFORM]; const oldOverrides = oldState?.byPlatform?.[oldPlatformKey]; - if (!oldOverrides || Object.keys(oldOverrides).length === 0) { + + if (oldOverrides && Object.keys(oldOverrides).length > 0) { + const translated: Record = {}; + for (const [id, keys] of Object.entries(oldOverrides)) { + translated[id] = keys === null ? null : translateKeyString(keys); + } + localStorage.setItem( + STORE_KEY, + JSON.stringify({ state: { overrides: translated }, version: 0 }), + ); + console.log( + `[hotkeys] Migrated ${Object.keys(translated).length} override(s)`, + ); + } else { console.log("[hotkeys] Migration skipped — no old overrides found"); - return; } - - localStorage.setItem( - "hotkey-overrides", - JSON.stringify({ state: { overrides: oldOverrides }, version: 0 }), - ); - console.log( - `[hotkeys] Migrated ${Object.keys(oldOverrides).length} override(s)`, - ); } catch (error) { console.log("[hotkeys] Migration failed, starting fresh:", error); } + + localStorage.setItem(MIGRATED_FLAG, "1"); } diff --git a/bun.lock b/bun.lock index 45e38e5c128..9279337ecb0 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.4.7", + "version": "1.5.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",