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
↓ peach-browser-616b4528.tar.gzatlas at this save
files changed3 files
src/background/vault.ts+8 −0src/shared/shortcuts.test.ts+113 −0src/shared/shortcuts.ts+185 −0src/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 = {};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+});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+}