Files
yellowjacket/frontend/src/store/shortcuts-store.ts
T
yonluandClaude Opus 5 162c68769f feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place —
a real TypeScript module tree nested by Go import path, generated by
wails3's static analyser rather than by building the app and running
it.  The @go alias absorbs the constant prefix, so a call site imports
'@go/library/library.js' and the codemod over all 93 sites was a
specifier rewrite plus splitting @go/models' namespaces into one
import per package.

The 12 SetContext bindings and the fake `context` model are gone, as
Phase 2's ServiceStartup port promised: 272 methods across 12
services, none of them plumbing.

@runtime/runtime is now a local shim (src/wails/runtime.ts) over
@wailsio/runtime, so the 22 EventsOn imports are untouched.  It
unwraps v3's WailsEvent into v2's callback shape, which is exact here:
nothing in backend/events passes more than one data argument, and v3
only packs arguments into a slice when there is more than one.

v3 tells the truth about two things v2 lied about, and that is most of
the diff.  A Go nil slice really does arrive as JSON null, and a Go
named string type really is an enum; v2 typed them as T[] and string.
utils/binding.ts states the app's actual contract — an absent list is
an empty list — once, at the boundary where it is true, and also drops
the CancellablePromise the app never cancels.  Four test fixtures
widen an enum field back to its value union.

Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts
still fakes window.go, which v3 does not have, so `make ui-test` is
broken and harness.test.ts fails to compile on EventsEmit.  That test
also asserts v2 ordering that no longer holds — v3's Events.Emit calls
the backend and does not notify in-page listeners at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:48:38 -04:00

193 lines
5.4 KiB
TypeScript

import { EventsOn } from '@runtime/runtime';
import {
GetShortcuts,
SetShortcut,
SetShortcuts,
ResetShortcuts,
} from '@go/config/config.js';
import { Events } from '../events';
import { dictByName } from '@utils/binding';
export interface ShortcutsState {
bindings: Map<string, string>; // action → key combo
loaded: boolean;
}
type Subscriber = () => void;
/** Panel scope prefixes for scope-aware lookups. An action with one of
* these prefixes is only ever resolved inside its own panel, so it can
* reuse a key the global table also binds. */
const PANEL_PREFIXES = ['tracklist.', 'autotag.'] as const;
/**
* ShortcutsStore holds the current keyboard shortcut bindings
* and syncs them with the Go backend via Wails bindings.
*/
class ShortcutsStore {
private state: ShortcutsState = {
bindings: new Map(),
loaded: false,
};
private subscribers = new Set<Subscriber>();
private notifyQueued = false;
constructor() {
this.initializeEventListeners();
this.loadFromBackend();
}
// ===================================================================
// WAILS EVENT BRIDGE
// ===================================================================
private initializeEventListeners(): void {
EventsOn(
Events.ShortcutsConfigChanged,
(data: Record<string, string>) => {
this.state = {
bindings: new Map(Object.entries(data)),
loaded: true,
};
this.notify();
},
);
}
private async loadFromBackend(): Promise<void> {
try {
const raw = await dictByName(GetShortcuts());
this.state = {
bindings: new Map(Object.entries(raw)),
loaded: true,
};
this.notify();
} catch {
// Use empty bindings on failure; they'll be populated
// when the backend pushes a ShortcutsConfigChanged event.
this.state.loaded = true;
this.notify();
}
}
// ===================================================================
// STATE ACCESS
// ===================================================================
getState(): Readonly<ShortcutsState> {
return this.state;
}
/** Returns the full action→key bindings map. */
getBindings(): Map<string, string> {
return this.state.bindings;
}
/** Look up the key combo for a given action. */
getKeyForAction(action: string): string | undefined {
return this.state.bindings.get(action);
}
/**
* Reverse lookup: find the action bound to a given key combo.
* If a panel scope is provided, panel-specific bindings are checked
* first, then global bindings.
*/
getActionForKey(
key: string,
scope?: string,
): string | undefined {
// If we have a panel scope, check panel-specific bindings first.
if (scope && scope.startsWith('panel:')) {
const panelPrefix = scope.replace('panel:', '') + '.';
for (const [action, boundKey] of this.state.bindings) {
if (
action.startsWith(panelPrefix) &&
boundKey === key
) {
return action;
}
}
}
// Fall back to global (non-panel) bindings.
for (const [action, boundKey] of this.state.bindings) {
if (boundKey !== key) continue;
const isPanel = PANEL_PREFIXES.some((p) =>
action.startsWith(p),
);
if (!isPanel) return action;
}
return undefined;
}
/**
* Check for binding conflicts. Returns the conflicting binding
* or null if no conflict.
*/
findConflict(
key: string,
_scope: string,
excludeAction: string,
): { action: string; key: string } | null {
for (const [action, boundKey] of this.state.bindings) {
if (action === excludeAction) continue;
if (boundKey === key) return { action, key: boundKey };
}
return null;
}
// ===================================================================
// ACTIONS
// ===================================================================
/** Update a single shortcut binding. */
async updateBinding(
action: string,
key: string,
): Promise<void> {
await SetShortcut(action, key);
}
/** Replace all bindings at once. */
async setAll(
bindings: Record<string, string>,
): Promise<void> {
await SetShortcuts(bindings);
}
/** Reset all shortcuts to defaults. */
async resetAll(): Promise<void> {
await ResetShortcuts();
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private notify(): void {
if (this.notifyQueued) return;
this.notifyQueued = true;
queueMicrotask(() => {
this.notifyQueued = false;
this.subscribers.forEach((cb) => cb());
});
}
}
// Singleton instance.
export const shortcutsStore = new ShortcutsStore();