616b4528790d

phase A: add shared/shortcuts.ts — ShortcutAction union of 13 actions, Keybinding type, DEFAULT_BINDINGS (c/u/t/n/e/d/L=ctrl-l/?=shift+?/↑↓/ctrl+1/ctrl+,), matchKeybinding/keybindingFromEvent/formatKeybinding/findConflict helpers; add shortcuts? to VaultSettings; 15 unit tests cover matcher/formatter/conflict/override merging

time
2026-04-26 21:15 UTC · 6 months ago
machine
koh2
parent
91c0d36709ba
3 files src/background/vault.ts+8 −0src/shared/shortcuts.test.ts+113 −0src/shared/shortcuts.ts+185 −0
src/background/vault.ts+8 −0
@@ -68,6 +68,14 @@6868   trashRetentionDays?: number;6969   manualSummonDomains?: Record<string, ManualSummonRecord>;7070   declinedCardFingerprints?: string[];71+  /**72+   * User overrides for keyboard shortcuts. Keys are ShortcutAction names73+   * (see src/shared/shortcuts.ts). Missing actions fall through to74+   * DEFAULT_BINDINGS — `getActiveBindings(settings.shortcuts)` is the75+   * canonical lookup. Storing only the deltas keeps the vault payload76+   * small and lets default updates land for users who never rebind.77+   */78+  shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;7179 }7280 7381 let settings: VaultSettings = {};

view file

src/shared/shortcuts.test.ts+113 −0
@@ -0,0 +1,113 @@1+// @vitest-environment jsdom2+3+import { describe, it, expect } from "vitest";4+import {5+  DEFAULT_BINDINGS,6+  findConflict,7+  formatKeybinding,8+  getActiveBindings,9+  keybindingFromEvent,10+  matchKeybinding,11+} from "./shortcuts";12+13+function ev(key: string, mods: { ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean } = {}): KeyboardEvent {14+  return new KeyboardEvent("keydown", {15+    key,16+    ctrlKey:  Boolean(mods.ctrl),17+    metaKey:  Boolean(mods.meta),18+    shiftKey: Boolean(mods.shift),19+    altKey:   Boolean(mods.alt),20+  });21+}22+23+describe("matchKeybinding", () => {24+  it("matches case-insensitively on the key letter", () => {25+    expect(matchKeybinding(ev("c"), { key: "c" })).toBe(true);26+    expect(matchKeybinding(ev("C", { shift: true }), { key: "c", shift: true })).toBe(true);27+  });28+29+  it("requires every modifier to match exactly", () => {30+    expect(matchKeybinding(ev("l", { ctrl: true }), { key: "l", ctrl: true })).toBe(true);31+    expect(matchKeybinding(ev("l"),                  { key: "l", ctrl: true })).toBe(false);32+    expect(matchKeybinding(ev("l", { ctrl: true, shift: true }), { key: "l", ctrl: true })).toBe(false);33+  });34+35+  it("rejects when key differs", () => {36+    expect(matchKeybinding(ev("c"), { key: "n" })).toBe(false);37+  });38+});39+40+describe("getActiveBindings", () => {41+  it("returns full default map when no overrides", () => {42+    const active = getActiveBindings(undefined);43+    expect(active.copy_password).toEqual(DEFAULT_BINDINGS.copy_password);44+    expect(Object.keys(active).length).toBe(Object.keys(DEFAULT_BINDINGS).length);45+  });46+47+  it("user override replaces default for that action only", () => {48+    const active = getActiveBindings({ copy_password: { key: "p", ctrl: true } });49+    expect(active.copy_password).toEqual({ key: "p", ctrl: true });50+    expect(active.copy_username).toEqual(DEFAULT_BINDINGS.copy_username);51+  });52+});53+54+describe("keybindingFromEvent", () => {55+  it("returns null for bare modifier presses", () => {56+    expect(keybindingFromEvent(ev("Shift"))).toBeNull();57+    expect(keybindingFromEvent(ev("Control"))).toBeNull();58+    expect(keybindingFromEvent(ev("Meta"))).toBeNull();59+    expect(keybindingFromEvent(ev("Alt"))).toBeNull();60+  });61+62+  it("captures key + modifiers from the event", () => {63+    const b = keybindingFromEvent(ev("k", { ctrl: true, shift: true }));64+    expect(b).toEqual({ key: "k", ctrl: true, shift: true });65+  });66+67+  it("omits unset modifier flags rather than storing false", () => {68+    const b = keybindingFromEvent(ev("a"));69+    expect(b).toEqual({ key: "a" });70+  });71+});72+73+describe("formatKeybinding", () => {74+  it("uses Ctrl+K style on non-mac", () => {75+    expect(formatKeybinding({ key: "k", ctrl: true }, false)).toBe("Ctrl+K");76+  });77+78+  it("uses ⌘ glyph on mac", () => {79+    expect(formatKeybinding({ key: "k", meta: true }, true)).toBe("⌘K");80+  });81+82+  it("renames named keys (arrows, escape, enter)", () => {83+    expect(formatKeybinding({ key: "ArrowUp" }, false)).toBe("↑");84+    expect(formatKeybinding({ key: "Escape" }, false)).toBe("Esc");85+    expect(formatKeybinding({ key: "Enter" }, false)).toBe("↵");86+    expect(formatKeybinding({ key: " " }, false)).toBe("Space");87+  });88+89+  it("orders mac modifiers ⌃⌥⇧⌘ then key", () => {90+    expect(formatKeybinding({ key: "k", ctrl: true, alt: true, shift: true, meta: true }, true))91+      .toBe("⌃⌥⇧⌘K");92+  });93+});94+95+describe("findConflict", () => {96+  it("returns the conflicting action when binding collides", () => {97+    const bindings = getActiveBindings({});98+    const conflict = findConflict(bindings, { key: "c" });99+    expect(conflict).toBe("copy_password");100+  });101+102+  it("excludes the action being rebound from conflict checks", () => {103+    const bindings = getActiveBindings({});104+    const conflict = findConflict(bindings, { key: "c" }, "copy_password");105+    expect(conflict).toBeNull();106+  });107+108+  it("returns null when binding is unique", () => {109+    const bindings = getActiveBindings({});110+    const conflict = findConflict(bindings, { key: "z", ctrl: true, shift: true });111+    expect(conflict).toBeNull();112+  });113+});

view file

src/shared/shortcuts.ts+185 −0
@@ -0,0 +1,185 @@1+/**2+ * shortcuts.ts — keybinding schema, defaults, matcher, formatter.3+ *4+ * Shared across popup views and Settings. The popup reads5+ * `getActiveBindings(settings)` once on init and on every settings update;6+ * each handler tests `event` against the binding it cares about via7+ * `matchKeybinding(event, binding)`. Capture-key flow in Settings uses8+ * `keybindingFromEvent(event)` to convert a captured keydown into a9+ * Keybinding object.10+ *11+ * Bindings store `event.key` (layout-aware), not `event.code`. That keeps12+ * the displayed shortcut accurate for the user's keyboard layout — a13+ * QWERTY user sees "C", an AZERTY user typing the same physical key sees14+ * what their layout produces.15+ */16+17+export type ShortcutAction =18+  | "copy_password"19+  | "copy_username"20+  | "copy_totp"21+  | "search"22+  | "new_entry"23+  | "edit_entry"24+  | "delete_entry"25+  | "lock_vault"26+  | "show_help"27+  | "nav_prev"28+  | "nav_next"29+  | "jump_vault"30+  | "jump_settings";31+32+export interface Keybinding {33+  /** event.key value — case-preserved for letters but matched case-insensitively. */34+  key: string;35+  ctrl?: boolean;36+  meta?: boolean;37+  shift?: boolean;38+  alt?: boolean;39+}40+41+export interface ShortcutDescriptor {42+  action: ShortcutAction;43+  label: string;44+  /** Where this shortcut is meaningful — used for grouping in the help dialog. */45+  scope: "entry" | "list" | "global";46+  /** Hidden from the rebind UI when true (Escape, etc. — not user-customizable). */47+  fixed?: boolean;48+}49+50+export const SHORTCUT_DESCRIPTORS: ShortcutDescriptor[] = [51+  { action: "copy_password",  label: "Copy password",          scope: "entry"  },52+  { action: "copy_username",  label: "Copy username",          scope: "entry"  },53+  { action: "copy_totp",      label: "Copy 2FA code",          scope: "entry"  },54+  { action: "edit_entry",     label: "Edit entry",             scope: "entry"  },55+  { action: "delete_entry",   label: "Move entry to trash",    scope: "entry"  },56+  { action: "nav_prev",       label: "Previous entry",         scope: "entry"  },57+  { action: "nav_next",       label: "Next entry",             scope: "entry"  },58+  { action: "search",         label: "Search vault",           scope: "global" },59+  { action: "new_entry",      label: "New entry",              scope: "global" },60+  { action: "lock_vault",     label: "Lock vault",             scope: "global" },61+  { action: "jump_vault",     label: "Jump to vault",          scope: "global" },62+  { action: "jump_settings",  label: "Jump to settings",       scope: "global" },63+  { action: "show_help",      label: "Show keyboard shortcuts", scope: "global" },64+];65+66+export const DEFAULT_BINDINGS: Record<ShortcutAction, Keybinding> = {67+  copy_password:  { key: "c" },68+  copy_username:  { key: "u" },69+  copy_totp:      { key: "t" },70+  search:         { key: "/" },71+  new_entry:      { key: "n" },72+  edit_entry:     { key: "e" },73+  delete_entry:   { key: "d" },74+  lock_vault:     { key: "l", ctrl: true },75+  show_help:      { key: "?", shift: true },76+  nav_prev:       { key: "ArrowUp" },77+  nav_next:       { key: "ArrowDown" },78+  jump_vault:     { key: "1", ctrl: true },79+  jump_settings:  { key: ",", ctrl: true },80+};81+82+/** Merge user overrides over defaults, returning a complete bindings map. */83+export function getActiveBindings(84+  overrides: Partial<Record<ShortcutAction, Keybinding>> | undefined85+): Record<ShortcutAction, Keybinding> {86+  if (!overrides) return { ...DEFAULT_BINDINGS };87+  return { ...DEFAULT_BINDINGS, ...overrides };88+}89+90+/** True when the keyboard event matches the binding (case-insensitive on key). */91+export function matchKeybinding(event: KeyboardEvent, binding: Keybinding): boolean {92+  if (event.key.toLowerCase() !== binding.key.toLowerCase()) return false;93+  if (Boolean(event.ctrlKey)  !== Boolean(binding.ctrl))  return false;94+  if (Boolean(event.metaKey)  !== Boolean(binding.meta))  return false;95+  if (Boolean(event.shiftKey) !== Boolean(binding.shift)) return false;96+  if (Boolean(event.altKey)   !== Boolean(binding.alt))   return false;97+  return true;98+}99+100+/**101+ * Build a Keybinding from a captured keydown event. Returns null when the102+ * event is a bare modifier (Shift, Ctrl, etc.) — the rebind UI keeps103+ * listening until a real key arrives.104+ */105+export function keybindingFromEvent(event: KeyboardEvent): Keybinding | null {106+  const k = event.key;107+  if (k === "Shift" || k === "Control" || k === "Meta" || k === "Alt") return null;108+  return {109+    key: k,110+    ctrl:  event.ctrlKey  || undefined,111+    meta:  event.metaKey  || undefined,112+    shift: event.shiftKey || undefined,113+    alt:   event.altKey   || undefined,114+  };115+}116+117+/**118+ * Format a binding for display. Uses platform conventions:119+ *   macOS: ⌘ ⌥ ⌃ ⇧120+ *   other: Ctrl Alt Shift Meta121+ * Caller passes `isMac` so this stays SSR/test-safe with no globals.122+ */123+export function formatKeybinding(binding: Keybinding, isMac = detectMac()): string {124+  const parts: string[] = [];125+  if (isMac) {126+    if (binding.ctrl)  parts.push("⌃");127+    if (binding.alt)   parts.push("⌥");128+    if (binding.shift) parts.push("⇧");129+    if (binding.meta)  parts.push("⌘");130+  } else {131+    if (binding.ctrl)  parts.push("Ctrl");132+    if (binding.alt)   parts.push("Alt");133+    if (binding.shift) parts.push("Shift");134+    if (binding.meta)  parts.push("Meta");135+  }136+  parts.push(formatKey(binding.key));137+  return isMac ? parts.join("") : parts.join("+");138+}139+140+function formatKey(key: string): string {141+  if (key === " ") return "Space";142+  if (key === "ArrowUp")    return "↑";143+  if (key === "ArrowDown")  return "↓";144+  if (key === "ArrowLeft")  return "←";145+  if (key === "ArrowRight") return "→";146+  if (key === "Escape")     return "Esc";147+  if (key === "Enter")      return "↵";148+  if (key === "Tab")        return "⇥";149+  if (key.length === 1) return key.toUpperCase();150+  return key;151+}152+153+function detectMac(): boolean {154+  if (typeof navigator === "undefined") return false;155+  return /Mac|iPhone|iPad/.test(navigator.platform);156+}157+158+/**159+ * Find the first action whose binding matches. Returns null on no match.160+ * Used by the rebind UI's conflict-detection step.161+ */162+export function findConflict(163+  bindings: Record<ShortcutAction, Keybinding>,164+  candidate: Keybinding,165+  excludeAction?: ShortcutAction,166+): ShortcutAction | null {167+  for (const action of Object.keys(bindings) as ShortcutAction[]) {168+    if (action === excludeAction) continue;169+    if (matchKeybinding(eventLikeFromBinding(bindings[action]), candidate)) {170+      return action;171+    }172+  }173+  return null;174+}175+176+/** Synthesize a KeyboardEvent-shaped object so matchKeybinding can compare two bindings. */177+function eventLikeFromBinding(b: Keybinding): KeyboardEvent {178+  return {179+    key: b.key,180+    ctrlKey:  Boolean(b.ctrl),181+    metaKey:  Boolean(b.meta),182+    shiftKey: Boolean(b.shift),183+    altKey:   Boolean(b.alt),184+  } as KeyboardEvent;185+}

view file