summaryrefslogtreecommitdiff
path: root/apps/web/src/actions/keybindings/persistence.ts
blob: ced44bd0a634ff2ba561045710b1693440b67c22 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import type { ShortcutKey } from "@/actions/keybinding";
import { isShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "@/actions";
import { isActionWithOptionalArgs } from "@/actions";
import { runMigrations } from "./migrations";
import {
	getPersistedKeybindingsState,
	type PersistedKeybindingsState,
} from "./persisted-state";

export interface DecodedKeybindingsState {
	keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
	isCustomized: boolean;
}

export function serializeKeybindingsState({
	keybindings,
	isCustomized,
}: DecodedKeybindingsState): PersistedKeybindingsState {
	return {
		keybindings: Object.fromEntries(keybindings),
		isCustomized,
	};
}

export function migratePersistedKeybindingsState({
	state,
	fromVersion,
}: {
	state: unknown;
	fromVersion: number;
}): unknown {
	return runMigrations({ state, fromVersion });
}

/**
 * Decode a persisted/migrated keybindings blob into the in-memory shape.
 *
 * Lossy by design: invalid entries are dropped and a warning is emitted, so the
 * user falls back to (mostly) sensible defaults instead of a broken store.
 * Returns `null` if the top-level shape is unrecognizable, in which case the
 * caller should keep its current state.
 */
export function decodePersistedKeybindingsState({
	state,
}: {
	state: unknown;
}): DecodedKeybindingsState | null {
	const persisted = getPersistedKeybindingsState({ state });
	if (!persisted) {
		console.warn(
			"[keybindings] Persisted state has unexpected shape; keeping current keybindings.",
			state,
		);
		return null;
	}

	const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>();
	const dropped: Array<{ key: string; action: string | undefined }> = [];
	for (const [key, action] of Object.entries(persisted.keybindings)) {
		if (action === undefined) continue;
		if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) {
			dropped.push({ key, action });
			continue;
		}

		keybindings.set(key, action);
	}

	if (dropped.length > 0) {
		console.warn(
			"[keybindings] Dropped invalid persisted entries:",
			dropped,
		);
	}

	return {
		keybindings,
		isCustomized: persisted.isCustomized,
	};
}

/**
 * Parse a user-supplied keybindings configuration (typically the output of
 * `JSON.parse` on an imported file).
 *
 * Strict by design: throws on the first invalid entry so the caller can surface
 * the failure to the user instead of silently producing a half-applied import.
 * Accepts `unknown` because the input has already crossed a trust boundary.
 */
export function parseImportedKeybindings({
	config,
}: {
	config: unknown;
}): Map<ShortcutKey, TActionWithOptionalArgs> {
	if (typeof config !== "object" || config === null || Array.isArray(config)) {
		throw new Error("Imported keybindings must be a JSON object");
	}

	const result = new Map<ShortcutKey, TActionWithOptionalArgs>();
	for (const [key, action] of Object.entries(config)) {
		if (action === undefined) continue;
		if (typeof action !== "string") {
			throw new Error(
				`Invalid action for "${key}": expected string, got ${typeof action}`,
			);
		}
		if (!isShortcutKey(key)) {
			throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`);
		}
		if (!isActionWithOptionalArgs(action)) {
			throw new Error(
				`Invalid action for "${key}": ${JSON.stringify(action)}`,
			);
		}
		result.set(key, action);
	}
	return result;
}