feat(renderer): command store persists userBindings (Phase 6)

- add userBindings map (commandId -> combo string)
- setUserBinding / clearUserBinding / getUserBinding actions
- persist middleware with partialize: handlers are runtime-only,
  userBindings are serialized
- TDD: 7 persistence tests + existing 10 command-store tests still pass
- 162/162 total tests pass
This commit is contained in:
Amit Haridas
2026-06-05 16:02:26 +05:30
parent b1e16af62d
commit 813582aa8d
2 changed files with 108 additions and 18 deletions
+49 -18
View File
@@ -1,35 +1,66 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type CommandId = string; export type CommandId = string;
export type CommandHandler = (args?: unknown) => void | Promise<void>; export type CommandHandler = (args?: unknown) => void | Promise<void>;
/**
* 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<string, string>;
interface CommandState { interface CommandState {
handlers: Record<string, CommandHandler>; handlers: Record<string, CommandHandler>;
userBindings: UserBindings;
register: (id: CommandId, handler: CommandHandler) => void; register: (id: CommandId, handler: CommandHandler) => void;
registerMany: (handlers: Record<string, CommandHandler>) => void; registerMany: (handlers: Record<string, CommandHandler>) => void;
unregister: (id: CommandId) => void; unregister: (id: CommandId) => void;
dispatch: (id: CommandId, args?: unknown) => void; dispatch: (id: CommandId, args?: unknown) => void;
get: (id: CommandId) => CommandHandler | undefined; get: (id: CommandId) => CommandHandler | undefined;
setUserBinding: (id: CommandId, combo: string) => void;
clearUserBinding: (id: CommandId) => void;
getUserBinding: (id: CommandId) => string | undefined;
} }
export const useCommandStore = create<CommandState>((set, get) => ({ export const useCommandStore = create<CommandState>()(
handlers: {}, persist(
register: (id, handler) => (set, get) => ({
set((s) => ({ handlers: { ...s.handlers, [id]: handler } })), handlers: {},
registerMany: (handlers) => userBindings: {},
set((s) => ({ handlers: { ...s.handlers, ...handlers } })), register: (id, handler) =>
unregister: (id) => set((s) => ({ handlers: { ...s.handlers, [id]: handler } })),
set((s) => { registerMany: (handlers) =>
const next = { ...s.handlers }; set((s) => ({ handlers: { ...s.handlers, ...handlers } })),
delete next[id]; unregister: (id) =>
return { handlers: next }; 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]; name: 'mc-command-store',
if (handler) { // Only persist user-customizable bindings; handlers are runtime-only.
void handler(args); partialize: (state) => ({ userBindings: state.userBindings }),
} }
}, )
get: (id) => get().handlers[id], );
}));
@@ -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();
});
});