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
1 file src/popup/App.tsx+83 −49
src/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) {

view file