Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export function captureHotkeyFromEvent(event: KeyboardEvent): string | null {
if (isIgnorableKey(key)) return null;

const isFKey = /^f([1-9]|1[0-2])$/.test(key);
if (!isFKey && !event.ctrlKey && !event.metaKey) return null;
// On Mac, Option is a legitimate shortcut modifier (e.g. ⌥⌫ for delete-word).
// Elsewhere, Alt is the menu key and AltGr masquerades as ctrl+alt, so we
// still require ctrl/meta.
const altIsAppModifier = PLATFORM === "mac" && event.altKey;
if (!isFKey && !event.ctrlKey && !event.metaKey && !altIsAppModifier) {
return null;
}

const modifiers = new Set<string>();
if (event.metaKey) modifiers.add("meta");
Expand Down Expand Up @@ -55,6 +61,11 @@ const OS_RESERVED: Record<Platform, Set<string>> = {
linux: new Set(["alt+f4", "alt+tab"].map(canonicalizeChord)),
};

function isMacAltOnlyChord(canonical: string): boolean {
const mods = new Set(canonical.split("+").slice(0, -1));
return mods.has("alt") && !mods.has("meta") && !mods.has("ctrl");
}

function checkReserved(
keys: string,
): { reason: string; severity: "error" | "warning" } | null {
Expand All @@ -63,6 +74,11 @@ function checkReserved(
return { reason: "Reserved by terminal", severity: "error" };
if (OS_RESERVED[PLATFORM].has(canonical))
return { reason: "Reserved by OS", severity: "warning" };
if (PLATFORM === "mac" && isMacAltOnlyChord(canonical))
return {
reason: "Option shortcuts may prevent typing special characters",
severity: "warning",
};
return null;
}

Expand Down
88 changes: 66 additions & 22 deletions apps/desktop/src/renderer/hotkeys/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { electronTrpcClient } from "renderer/lib/trpc-client";
import { PLATFORM } from "./registry";
import { isUSCompatibleLayout } from "./utils/detectUSLayout";
import { sanitizeOverride } from "./utils/sanitizeOverride";

const MIGRATION_MARKER_KEY = "hotkey-overrides-migrated-v2";
Expand All @@ -18,10 +19,71 @@ const PLATFORM_MAP = {
linux: "linux",
} as const;

function sanitizeOverridesMap(
raw: Record<string, string | null>,
assumeUSMacLayout: boolean,
): { cleaned: Record<string, string | null>; dropped: number } {
const cleaned: Record<string, string | null> = {};
let dropped = 0;
for (const [id, value] of Object.entries(raw)) {
const sanitized = sanitizeOverride(value, { assumeUSMacLayout });
if (sanitized === undefined) {
dropped++;
continue;
}
cleaned[id] = sanitized;
}
return { cleaned, dropped };
}

export async function migrateHotkeyOverrides(): Promise<void> {
if (localStorage.getItem(MIGRATION_MARKER_KEY)) return;

try {
const assumeUSMacLayout =
PLATFORM === "mac" ? await isUSCompatibleLayout() : true;

// FORK NOTE: If the user already has a hotkey-overrides entry in
// localStorage from an earlier migration, don't overwrite it with the
// potentially stale legacy tRPC store value — but still re-run the
// sanitizer over those entries. The -v2 marker bump specifically exists
// so users whose pre-sanitizer localStorage contains corrupt overrides
// get them re-sanitized (or dropped) once.
const existingRaw = localStorage.getItem("hotkey-overrides");
if (existingRaw) {
try {
const parsed = JSON.parse(existingRaw) as {
state?: { overrides?: Record<string, string | null> };
};
const overrides = parsed?.state?.overrides;
if (overrides && Object.keys(overrides).length > 0) {
const { cleaned, dropped } = sanitizeOverridesMap(
overrides,
assumeUSMacLayout,
);
localStorage.setItem(
"hotkey-overrides",
JSON.stringify({ state: { overrides: cleaned }, version: 0 }),
);
console.log(
`[hotkeys] Re-sanitized ${Object.keys(cleaned).length} localStorage override(s)` +
(dropped > 0 ? `, dropped ${dropped} invalid` : ""),
);
} else {
console.log(
"[hotkeys] Migration skipped — localStorage overrides empty",
);
}
} catch (parseError) {
console.log(
"[hotkeys] Failed to parse existing localStorage overrides, leaving untouched:",
parseError,
);
}
localStorage.setItem(MIGRATION_MARKER_KEY, "1");
return;
}

const oldState = await electronTrpcClient.uiState.hotkeys.get.query();
const oldPlatformKey = PLATFORM_MAP[PLATFORM];
const oldOverrides = oldState?.byPlatform?.[oldPlatformKey];
Expand All @@ -31,28 +93,10 @@ export async function migrateHotkeyOverrides(): Promise<void> {
return;
}

// If the user already has a hotkey-overrides entry in localStorage (set
// after the v1 migration), preserve it rather than overwriting with the
// potentially stale legacy tRPC store value.
const existingRaw = localStorage.getItem("hotkey-overrides");
if (existingRaw) {
localStorage.setItem(MIGRATION_MARKER_KEY, "1");
console.log(
"[hotkeys] Migration skipped — localStorage overrides already present",
);
return;
}

const cleaned: Record<string, string | null> = {};
let dropped = 0;
for (const [id, raw] of Object.entries(oldOverrides)) {
const sanitized = sanitizeOverride(raw);
if (sanitized === undefined) {
dropped++;
continue;
}
cleaned[id] = sanitized;
}
const { cleaned, dropped } = sanitizeOverridesMap(
oldOverrides,
assumeUSMacLayout,
);

localStorage.setItem(
"hotkey-overrides",
Expand Down
52 changes: 26 additions & 26 deletions apps/desktop/src/renderer/hotkeys/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,21 @@ export const HOTKEYS_REGISTRY = {
category: "Workspace",
},
PREV_WORKSPACE: {
key: { mac: null, windows: null, linux: null },
key: {
mac: "meta+alt+up",
windows: "ctrl+shift+alt+up",
linux: "ctrl+shift+alt+up",
},
label: "Previous Workspace",
category: "Workspace",
description: "Navigate to the previous workspace in the sidebar",
},
NEXT_WORKSPACE: {
key: { mac: null, windows: null, linux: null },
key: {
mac: "meta+alt+down",
windows: "ctrl+shift+alt+down",
linux: "ctrl+shift+alt+down",
},
label: "Next Workspace",
category: "Workspace",
description: "Navigate to the next workspace in the sidebar",
Expand Down Expand Up @@ -321,8 +329,8 @@ export const HOTKEYS_REGISTRY = {
SCROLL_TO_BOTTOM: {
key: {
mac: "meta+shift+down",
windows: "ctrl+shift+alt+down",
linux: "ctrl+shift+alt+down",
windows: "ctrl+end",
linux: "ctrl+end",
},
label: "Scroll to Bottom",
category: "Terminal",
Expand All @@ -343,53 +351,45 @@ export const HOTKEYS_REGISTRY = {
category: "Terminal",
},
PREV_TAB: {
key: { mac: null, windows: null, linux: null },
key: {
mac: "meta+alt+left",
windows: "ctrl+shift+alt+left",
linux: "ctrl+shift+alt+left",
},
label: "Previous Tab",
category: "Terminal",
description: "Focus the previous tab in the active workspace",
},
NEXT_TAB: {
key: { mac: null, windows: null, linux: null },
key: {
mac: "meta+alt+right",
windows: "ctrl+shift+alt+right",
linux: "ctrl+shift+alt+right",
},
label: "Next Tab",
category: "Terminal",
description: "Focus the next tab in the active workspace",
},
FOCUS_PANE_LEFT: {
key: {
mac: "meta+alt+left",
windows: "ctrl+shift+alt+left",
linux: "ctrl+shift+alt+left",
},
key: { mac: null, windows: null, linux: null },
label: "Focus Pane Left",
category: "Terminal",
description: "Focus the pane to the left of the active pane",
},
FOCUS_PANE_RIGHT: {
key: {
mac: "meta+alt+right",
windows: "ctrl+shift+alt+right",
linux: "ctrl+shift+alt+right",
},
key: { mac: null, windows: null, linux: null },
label: "Focus Pane Right",
category: "Terminal",
description: "Focus the pane to the right of the active pane",
},
FOCUS_PANE_UP: {
key: {
mac: "meta+alt+up",
windows: "ctrl+shift+alt+up",
linux: "ctrl+shift+alt+up",
},
key: { mac: null, windows: null, linux: null },
label: "Focus Pane Up",
category: "Terminal",
description: "Focus the pane above the active pane",
},
FOCUS_PANE_DOWN: {
key: {
mac: "meta+alt+down",
windows: "ctrl+shift+alt+down",
linux: "ctrl+shift+alt+down",
},
key: { mac: null, windows: null, linux: null },
label: "Focus Pane Down",
category: "Terminal",
description: "Focus the pane below the active pane",
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/renderer/hotkeys/utils/detectUSLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Chromium's Keyboard Map API tells us how each physical key is labeled on
// the active layout. Used to gate the Mac Option dead-key rewrites in
// sanitizeOverride — on non-US layouts, Option+<letter> produces different
// glyphs than US (e.g., German Option+Q = •), so our US-based rewrite table
// would produce wrong bindings.
//
// Fallback is optimistic (returns `true`) because:
// - `navigator.keyboard` is gated on secure contexts; packaged Electron
// renderers on file:// won't expose it, and we'd rather rewrite than drop
// for the common case.
// - Non-Mac users are unaffected either way (the dead-key glyphs aren't
// typeable at all, so detection is moot).

interface KeyboardLayoutMap extends ReadonlyMap<string, string> {}
interface Keyboard {
getLayoutMap?: () => Promise<KeyboardLayoutMap>;
}

let cached: Promise<boolean> | null = null;

export function isUSCompatibleLayout(): Promise<boolean> {
if (cached) return cached;
cached = probe();
return cached;
}

async function probe(): Promise<boolean> {
const keyboard = (navigator as Navigator & { keyboard?: Keyboard }).keyboard;
if (!keyboard?.getLayoutMap) return true;
try {
const map = await keyboard.getLayoutMap();
return (
map.get("KeyA") === "a" &&
map.get("KeyQ") === "q" &&
map.get("KeyW") === "w" &&
map.get("KeyZ") === "z" &&
map.get("Semicolon") === ";" &&
map.get("Quote") === "'"
);
} catch {
return true;
}
}

// Exposed for tests — resets the cached probe result.
export function resetUSLayoutCacheForTests(): void {
cached = null;
}
Loading
Loading