Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions apps/desktop/src/renderer/hotkeys/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
// 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",
});
});
});
61 changes: 45 additions & 16 deletions apps/desktop/src/renderer/hotkeys/migrate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, string> = {
"[": "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<void> {
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<string, string | null> = {};
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");
}
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading