diff --git a/src/renderer/stores/command-store.ts b/src/renderer/stores/command-store.ts index 301e401..78f2ffd 100644 --- a/src/renderer/stores/command-store.ts +++ b/src/renderer/stores/command-store.ts @@ -1,35 +1,66 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; export type CommandId = string; export type CommandHandler = (args?: unknown) => void | Promise; +/** + * User-customizable key binding for a command. + * Format: same as useShortcut combo ('mod+s', 'mod+shift+s', etc.) + * Undefined means "use the default binding" (if any). + */ +export type UserBindings = Record; + interface CommandState { handlers: Record; + userBindings: UserBindings; register: (id: CommandId, handler: CommandHandler) => void; registerMany: (handlers: Record) => void; unregister: (id: CommandId) => void; dispatch: (id: CommandId, args?: unknown) => void; get: (id: CommandId) => CommandHandler | undefined; + setUserBinding: (id: CommandId, combo: string) => void; + clearUserBinding: (id: CommandId) => void; + getUserBinding: (id: CommandId) => string | undefined; } -export const useCommandStore = create((set, get) => ({ - handlers: {}, - register: (id, handler) => - set((s) => ({ handlers: { ...s.handlers, [id]: handler } })), - registerMany: (handlers) => - set((s) => ({ handlers: { ...s.handlers, ...handlers } })), - unregister: (id) => - set((s) => { - const next = { ...s.handlers }; - delete next[id]; - return { handlers: next }; +export const useCommandStore = create()( + persist( + (set, get) => ({ + handlers: {}, + userBindings: {}, + register: (id, handler) => + set((s) => ({ handlers: { ...s.handlers, [id]: handler } })), + registerMany: (handlers) => + set((s) => ({ handlers: { ...s.handlers, ...handlers } })), + unregister: (id) => + set((s) => { + const next = { ...s.handlers }; + delete next[id]; + return { handlers: next }; + }), + dispatch: (id, args) => { + const handler = get().handlers[id]; + if (handler) { + void handler(args); + } + }, + get: (id) => get().handlers[id], + setUserBinding: (id, combo) => + set((s) => ({ userBindings: { ...s.userBindings, [id]: combo } })), + clearUserBinding: (id) => + set((s) => { + const next = { ...s.userBindings }; + delete next[id]; + return { userBindings: next }; + }), + getUserBinding: (id) => get().userBindings[id], }), - dispatch: (id, args) => { - const handler = get().handlers[id]; - if (handler) { - void handler(args); + { + name: 'mc-command-store', + // Only persist user-customizable bindings; handlers are runtime-only. + partialize: (state) => ({ userBindings: state.userBindings }), } - }, - get: (id) => get().handlers[id], -})); + ) +); diff --git a/tests/unit/stores/command-store-persist.test.ts b/tests/unit/stores/command-store-persist.test.ts new file mode 100644 index 0000000..f7b0789 --- /dev/null +++ b/tests/unit/stores/command-store-persist.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useCommandStore } from '@/stores/command-store'; + +describe('useCommandStore — user bindings persistence', () => { + beforeEach(() => { + localStorage.clear(); + useCommandStore.setState({ + handlers: {}, + userBindings: {}, + }); + }); + + it('starts with empty userBindings', () => { + expect(useCommandStore.getState().userBindings).toEqual({}); + }); + + it('setUserBinding stores a combo for a command id', () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+shift+s'); + }); + + it('setUserBinding overwrites an existing binding for the same command', () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + useCommandStore.getState().setUserBinding('file.save', 'mod+alt+s'); + expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+alt+s'); + }); + + it('clearUserBinding removes a binding', () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + useCommandStore.getState().clearUserBinding('file.save'); + expect(useCommandStore.getState().userBindings['file.save']).toBeUndefined(); + }); + + it('clearUserBinding on a missing id is a no-op', () => { + expect(() => useCommandStore.getState().clearUserBinding('does.not.exist')).not.toThrow(); + }); + + it('getUserBinding returns the combo or undefined', () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + expect(useCommandStore.getState().getUserBinding('file.save')).toBe('mod+shift+s'); + expect(useCommandStore.getState().getUserBinding('does.not.exist')).toBeUndefined(); + }); + + it('persists userBindings to localStorage and restores on next mount', async () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + useCommandStore.getState().setUserBinding('view.toggleSidebar', 'mod+b'); + + // Force the persist middleware to flush + await useCommandStore.persist?.flush?.(); + + const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}'); + expect(stored.state.userBindings).toEqual({ + 'file.save': 'mod+shift+s', + 'view.toggleSidebar': 'mod+b', + }); + // Handlers must NOT be persisted (functions aren't serializable) + expect(stored.state.handlers).toBeUndefined(); + }); +});