mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user