a17baeac9c45
phase B: wire popup keyboard handler through bindings — replace hardcoded switch with SHORTCUT_HANDLERS map + matchKeybinding loop, load active bindings from settings on init, add new actions (copy_totp, edit_entry, delete_entry, jump_vault, jump_settings, show_help), input-focus and entry-scope guards preserve existing behavior; type-to-search remains a behavior (not an action)
- time
- 2026-04-26 21:17 UTC · 6 months ago
- machine
- koh2
- parent
- 616b4528790d
↓ peach-browser-a17baeac.tar.gzatlas at this save
files changed1 file
src/popup/App.tsx+83 −49src/popup/App.tsx+83 −49
@@ -10,6 +10,14 @@1010 import { PasskeyEntryView } from "./views/PasskeyEntry";1111 import { extractDomain } from "../shared/domain";1212 import { popupCopy } from "./copy";13+import {14+ DEFAULT_BINDINGS,15+ getActiveBindings,16+ matchKeybinding,17+ type Keybinding,18+ type ShortcutAction,19+} from "../shared/shortcuts";20+import { generateTOTP } from "../crypto/totp";1321 1422 const ImportView = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));1523 const ExportView = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));@@ -166,6 +174,8 @@166174 const [lockOnClose, setLockOnClose] = createSignal(true);167175 const [autoLockMinutes, setAutoLockMinutes] = createSignal(5);168176 const [breachMonitoring, setBreachMonitoring] = createSignal(true);177+ const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);178+ const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);169179 const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);170180 const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);171181 const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);@@ -313,6 +323,53 @@313323 chrome.runtime.onMessage.addListener(handleRuntimeMessage);314324 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));315325 326+ // Action handlers — keyed by ShortcutAction. The dispatcher below tests327+ // each binding against the event and runs the matching handler. New328+ // shortcuts only need an entry here; the binding is data, not code.329+ const SHORTCUT_HANDLERS: Partial<Record<ShortcutAction, () => void>> = {330+ copy_password: () => {331+ const entry = selectedEntry();332+ if (entry?.password) void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });333+ },334+ copy_username: () => {335+ const entry = selectedEntry();336+ if (entry?.username) void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });337+ },338+ copy_totp: () => {339+ const entry = selectedEntry();340+ if (!entry?.totp) return;341+ try {342+ const { code } = generateTOTP(entry.totp);343+ void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });344+ } catch { /* invalid TOTP secret — silent */ }345+ },346+ new_entry: () => handleAdd(),347+ edit_entry: () => {348+ const entry = selectedEntry();349+ if (!entry || entry.type === "passkey") return;350+ setEditEntry(entry);351+ setView("add");352+ },353+ delete_entry: () => {354+ if (selectedEntry()) void handleDelete();355+ },356+ lock_vault: () => { void handleHeaderLock(); },357+ search: () => openList(),358+ nav_prev: () => navigate(-1),359+ nav_next: () => navigate(1),360+ jump_vault: () => setView("entry"),361+ jump_settings: () => setView("settings"),362+ show_help: () => setShowShortcutsHelp(true),363+ };364+365+ // Actions that are MEANINGFUL only on the entry-detail view. Outside366+ // that scope the binding is dormant — pressing C while on Settings367+ // does nothing rather than awkwardly copying from a now-stale entry.368+ const ENTRY_SCOPED: ReadonlySet<ShortcutAction> = new Set([369+ "copy_password", "copy_username", "copy_totp",370+ "edit_entry", "delete_entry", "nav_prev", "nav_next",371+ ]);372+316373 const handleGlobalKeydown = (e: KeyboardEvent) => {317374 const v = view();318375 const inInput = (e.target as HTMLElement)?.tagName === "INPUT"@@ -324,69 +381,45 @@324381 !e.metaKey &&325382 !e.altKey;326383 327- // ── Escape ────────────────────────────────────────────────384+ // Help dialog: Escape closes it; eat the keypress so it doesn't385+ // also navigate away from the underlying view.386+ if (showShortcutsHelp()) {387+ if (e.key === "Escape") { e.preventDefault(); setShowShortcutsHelp(false); }388+ return;389+ }390+391+ // ── Escape — fixed binding (not user-customizable) ───────328392 if (e.key === "Escape") {329393 if (v === "list") { setView("entry"); return; }330394 if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }331395 }332396 333- // ── Focus search ─────────────────────────────────────────334- if ((e.key === "/" || (e.ctrlKey && e.key === "f")) && !inInput) {397+ // Bindings-driven dispatch. Single-key letter bindings without398+ // modifiers (c/u/t/n/e/d/?) are silently swallowed when focus is399+ // inside an input, so users can type into search/forms normally.400+ const bindings = shortcutBindings();401+ for (const action of Object.keys(bindings) as ShortcutAction[]) {402+ const binding = bindings[action];403+ if (!matchKeybinding(e, binding)) continue;404+ // Single-key letter bindings need the input-focus guard. Bindings405+ // with any modifier are safe — Ctrl+L doesn't conflict with typing.406+ const hasMod = binding.ctrl || binding.meta || binding.alt || binding.shift;407+ if (!hasMod && binding.key.length === 1 && inInput) return;408+ if (ENTRY_SCOPED.has(action) && v !== "entry") return;335409 e.preventDefault();336- openList();410+ SHORTCUT_HANDLERS[action]?.();337411 return;338412 }339413 414+ // Type-to-search: in entry view, any printable key not consumed by a415+ // shortcut above primes the list search seed and opens the list.416+ // Kept outside the bindings table because it's a behavior, not a417+ // discrete action — every printable character can trigger it.340418 if (v === "entry" && !inInput && isPrintableSearchKey) {341419 e.preventDefault();342420 primeListSearch(e.key);343421 return;344422 }345-346- // ── Lock ──────────────────────────────────────────────────347- if (e.ctrlKey && e.key === "l") {348- e.preventDefault();349- void handleHeaderLock();350- return;351- }352-353- // ── Entry-view shortcuts (not in input fields) ────────────354- if (v === "entry" && !inInput) {355- // c → copy password, u → copy username356- if (e.key === "c") {357- e.preventDefault();358- const entry = selectedEntry();359- if (entry?.password) {360- void popupCopy(entry.password, "Password").then((ok) => { if (ok) triggerRing(); });361- }362- return;363- }364- if (e.key === "u") {365- e.preventDefault();366- const entry = selectedEntry();367- if (entry?.username) {368- void popupCopy(entry.username, "Username").then((ok) => { if (ok) triggerRing(); });369- }370- return;371- }372- // n → new entry373- if (e.key === "n") {374- e.preventDefault();375- handleAdd();376- return;377- }378- // Arrow keys → navigate entries379- if (e.key === "ArrowLeft" || e.key === "ArrowUp") {380- e.preventDefault();381- navigate(-1);382- return;383- }384- if (e.key === "ArrowRight" || e.key === "ArrowDown") {385- e.preventDefault();386- navigate(1);387- return;388- }389- }390423 };391424 window.addEventListener("keydown", handleGlobalKeydown);392425 onCleanup(() => window.removeEventListener("keydown", handleGlobalKeydown));@@ -432,6 +465,7 @@432465 setAutoLockMinutes(typeof s.autoLockMinutes === "number" ? s.autoLockMinutes : 5);433466 setBreachMonitoring(s.breachMonitoring !== false);434467 setTrashRetentionDays(typeof s.trashRetentionDays === "number" ? s.trashRetentionDays : 30);468+ setShortcutBindings(getActiveBindings(s.shortcuts));435469 }436470 437471 async function applyTrashRetention(days: number) {