avtc-pi-settings-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/index.ts +30 -0
- package/package.json +65 -0
- package/src/ctx-store.ts +34 -0
- package/src/custom-validation.ts +45 -0
- package/src/factory.ts +233 -0
- package/src/model-presets.ts +101 -0
- package/src/normalization.ts +421 -0
- package/src/persistence.ts +115 -0
- package/src/preset-resolver.ts +66 -0
- package/src/schema.ts +144 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/type-definitions.ts +301 -0
- package/src/ui/modal-frame.ts +130 -0
- package/src/ui/picker-helpers.ts +68 -0
- package/src/ui/schema-tabs.ts +140 -0
- package/src/ui/settings-command.ts +321 -0
- package/src/ui/settings-modal.ts +414 -0
- package/src/ui/value-picker.ts +353 -0
- package/src/validation.ts +107 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Schema-driven settings normalization.
|
|
6
|
+
*
|
|
7
|
+
* Replaces per-field switch-based validation with a single loop over the schema.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createLogger, type Logger } from "avtc-pi-logger";
|
|
11
|
+
import type {
|
|
12
|
+
PresetElement,
|
|
13
|
+
PresetItem,
|
|
14
|
+
PresetPair,
|
|
15
|
+
PresetsSource,
|
|
16
|
+
PresetValue,
|
|
17
|
+
SettingSchema,
|
|
18
|
+
SettingsSchema,
|
|
19
|
+
} from "./schema.js";
|
|
20
|
+
import type { TypeContext } from "./type-definitions.js";
|
|
21
|
+
import { getTypeDefinition } from "./type-definitions.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Module-private sentinel for an invalid result from the core resolver.
|
|
25
|
+
* The load wrapper maps INVALID → default; the update wrapper maps INVALID → no update.
|
|
26
|
+
*/
|
|
27
|
+
const INVALID = Symbol("invalid");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort logger for rejected settings values. Created lazily so importing this module does
|
|
31
|
+
* no file work; the first rejection (load or update) initializes the writer. The logger never
|
|
32
|
+
* throws — a logging failure cannot break settings load.
|
|
33
|
+
*/
|
|
34
|
+
const NO_LOGGER_OPTIONS: Parameters<typeof createLogger>[1] = null;
|
|
35
|
+
let _logger: Logger | null = null;
|
|
36
|
+
function logger(): Logger {
|
|
37
|
+
if (_logger === null) _logger = createLogger("avtc-pi-settings-ui", NO_LOGGER_OPTIONS);
|
|
38
|
+
return _logger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve value aliases for a setting. If the raw value matches a valueAlias key,
|
|
43
|
+
* returns the mapped raw value; otherwise returns the raw value unchanged.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveValueAlias(rawValue: unknown, setting: SettingSchema): unknown {
|
|
46
|
+
if (typeof rawValue === "string" && setting.valueAliases && rawValue in setting.valueAliases) {
|
|
47
|
+
const alias = setting.valueAliases[rawValue];
|
|
48
|
+
if (alias !== undefined) return alias;
|
|
49
|
+
}
|
|
50
|
+
return rawValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the `TypeContext` for a setting — presets (as PresetPair[]) + min/max. A custom type's
|
|
55
|
+
* parse/format derives the null label itself from ctx.presets (the pair whose value is null).
|
|
56
|
+
*/
|
|
57
|
+
function buildTypeContext(setting: SettingSchema): TypeContext {
|
|
58
|
+
return { presets: resolveStaticPresets(setting), min: setting.min, max: setting.max };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The core single-setting resolver — the ONE gate used by load (`resolveValue`), update
|
|
63
|
+
* (`updateSetting`), and env-var deserialize (which calls `normalizeFromSchema`).
|
|
64
|
+
* Returns the resolved value, or the `INVALID` sentinel when the raw value is rejected.
|
|
65
|
+
*
|
|
66
|
+
* 8-step gate:
|
|
67
|
+
* 1. aliases 2. valueAliases 3. null-label string → null
|
|
68
|
+
4. null validity (null-preset — nullLabel defined)
|
|
69
|
+
5. parse-if-string (typeDef.parse — the type→parser registry)
|
|
70
|
+
6. type-match (`typeof === valueType`) 7. finite (number/duration) 8. `[min,max]` bounds
|
|
71
|
+
*/
|
|
72
|
+
export function resolveSettingValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
|
|
73
|
+
// 1. field aliases (old keys → this setting's id)
|
|
74
|
+
let value = raw[setting.id];
|
|
75
|
+
if (value === undefined && setting.aliases) {
|
|
76
|
+
for (const alias of setting.aliases) {
|
|
77
|
+
if (raw[alias] !== undefined) {
|
|
78
|
+
value = raw[alias];
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. value aliases (old value renames) — only for string values
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
value = resolveValueAlias(value, setting);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. null-label string → null (e.g. stored/typed "Infinite"/"Never"/"ask" → null).
|
|
90
|
+
const nullLabel = resolveNullLabel(setting);
|
|
91
|
+
if (typeof value === "string" && nullLabel !== undefined && value === nullLabel) {
|
|
92
|
+
value = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const typeDef = getTypeDefinition(setting.type);
|
|
96
|
+
|
|
97
|
+
// 4. null validity — keep null iff a null-preset exists (nullLabel derived from the effective
|
|
98
|
+
// presets). A type with no null-preset rejects null. Legitimate nulls never reach parse.
|
|
99
|
+
if (value === null) {
|
|
100
|
+
return nullLabel !== undefined ? null : INVALID;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 5. parse-if-string. Parse is string-only by signature; raw numbers/booleans
|
|
104
|
+
// never enter it.
|
|
105
|
+
if (typeof value === "string") {
|
|
106
|
+
const p = typeDef.parse(value, buildTypeContext(setting));
|
|
107
|
+
if (p === undefined) return INVALID;
|
|
108
|
+
value = p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 6. type-match — typeof must equal the type's declared valueType.
|
|
112
|
+
if (typeof value !== typeDef.valueType) return INVALID;
|
|
113
|
+
|
|
114
|
+
// 7. finite check (number/duration) — Infinity/NaN must not pass the bounds range test.
|
|
115
|
+
if (typeDef.valueType === "number" && !Number.isFinite(value as number)) return INVALID;
|
|
116
|
+
|
|
117
|
+
// 8. bounds — only for bounded types with a declared min/max.
|
|
118
|
+
if (typeDef.bounded === true) {
|
|
119
|
+
if (setting.min !== undefined && (value as number) < setting.min) return INVALID;
|
|
120
|
+
if (setting.max !== undefined && (value as number) > setting.max) return INVALID;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a value from raw data for a single setting (LOAD disposition).
|
|
128
|
+
* Thin wrapper over the core resolver: INVALID → default. Logs the rejection only when a value
|
|
129
|
+
* was actually PRESENT (an absent key is the normal default-fill path — not a rejection).
|
|
130
|
+
*/
|
|
131
|
+
function resolveValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
|
|
132
|
+
const result = resolveSettingValue(raw, setting);
|
|
133
|
+
if (result === INVALID) {
|
|
134
|
+
if (isSettingPresent(raw, setting)) logRejection(raw, setting);
|
|
135
|
+
return setting.defaultValue;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when the raw record carries a value for this setting (by id or any alias). */
|
|
141
|
+
function isSettingPresent(raw: Record<string, unknown>, setting: SettingSchema): boolean {
|
|
142
|
+
if (raw[setting.id] !== undefined) return true;
|
|
143
|
+
return setting.aliases?.some((alias) => raw[alias] !== undefined) ?? false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Emit a best-effort warn log when a setting value is rejected at load. Includes the valid
|
|
148
|
+
* options when available — preset values for an enum, or the [min,max] bounds for a bounded
|
|
149
|
+
* number/duration — so a silent reset-to-default is diagnosable. Never throws.
|
|
150
|
+
*/
|
|
151
|
+
function logRejection(raw: Record<string, unknown>, setting: SettingSchema): void {
|
|
152
|
+
try {
|
|
153
|
+
const rawValue = raw[setting.id];
|
|
154
|
+
const valid = validOptionsHint(setting);
|
|
155
|
+
const suffix = valid !== null ? ` (valid: ${valid})` : "";
|
|
156
|
+
// The raw value is interpolated verbatim — avtc-pi-logger sanitizes control chars (line
|
|
157
|
+
// forging) and truncates (runaway length) at the emit boundary, so this stays single-line.
|
|
158
|
+
logger().warn(`Setting '${setting.id}' rejected value '${String(rawValue)}'${suffix} - reset to default`);
|
|
159
|
+
} catch {
|
|
160
|
+
// logging is best-effort; never break a load on a logging failure
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A human-readable hint of the acceptable values for a setting, or null when no hint applies
|
|
166
|
+
* (e.g. a free-form string). Enum → comma-joined preset values; bounded number/duration → the
|
|
167
|
+
* [min,max] range (ASCII). Resolver-function presets (model list) yield no static values → null.
|
|
168
|
+
*/
|
|
169
|
+
function validOptionsHint(setting: SettingSchema): string | null {
|
|
170
|
+
const pairs = resolveStaticPresets(setting);
|
|
171
|
+
if (pairs.length > 0) {
|
|
172
|
+
return pairs.map(([, value]) => String(value)).join(", ");
|
|
173
|
+
}
|
|
174
|
+
const typeDef = getTypeDefinition(setting.type);
|
|
175
|
+
if (typeDef.bounded === true && (setting.min !== undefined || setting.max !== undefined)) {
|
|
176
|
+
const lower = setting.min !== undefined ? String(setting.min) : "-inf";
|
|
177
|
+
const upper = setting.max !== undefined ? String(setting.max) : "inf";
|
|
178
|
+
return `${lower}-${upper}`;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Test whether a `resolveSettingValue` result is the INVALID sentinel. The sentinel itself is
|
|
185
|
+
* module-private; this predicate is the public seam so `updateSetting` (factory.ts) can apply the
|
|
186
|
+
* update disposition (INVALID → no-update) without re-implementing the gate. (one gate)
|
|
187
|
+
*/
|
|
188
|
+
export function isInvalidResult(value: unknown): boolean {
|
|
189
|
+
return value === INVALID;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Normalize raw settings using schema definitions.
|
|
194
|
+
* Iterates schema entries, checks aliases, applies parsers, fills defaults.
|
|
195
|
+
* Replaces N-field switch statements with a single data-driven loop.
|
|
196
|
+
*/
|
|
197
|
+
export function normalizeFromSchema(raw: Record<string, unknown>, schema: SettingsSchema): Record<string, unknown> {
|
|
198
|
+
const result: Record<string, unknown> = {};
|
|
199
|
+
|
|
200
|
+
for (const setting of schema.settings) {
|
|
201
|
+
result[setting.id] = resolveValue(raw, setting);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate a schema's structural integrity at registration (`createSettingsExtension` — fail-fast
|
|
209
|
+
* so a consumer's malformed schema surfaces immediately, not as a mysterious runtime gap). Checks:
|
|
210
|
+
* (a) preset strings — every static-preset setting's bare strings parse
|
|
211
|
+
* (`normalizePresetElements` throws on an unparseable one), eagerly populating the cache so a
|
|
212
|
+
* runtime resolution never re-throws;
|
|
213
|
+
* (b) orphans — every setting id appears in some tab;
|
|
214
|
+
* (c) unresolved ids — every `tab.settingId` references a defined setting.
|
|
215
|
+
* Duplicate setting ids are also rejected (ambiguous lookup). Throws on the first violation.
|
|
216
|
+
*/
|
|
217
|
+
export function validateSchema(schema: SettingsSchema): void {
|
|
218
|
+
// (a) preset strings (also eagerly populates the static-presets cache)
|
|
219
|
+
for (const setting of schema.settings) {
|
|
220
|
+
resolveStaticPresets(setting);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const settingIds = new Set<string>();
|
|
224
|
+
for (const setting of schema.settings) {
|
|
225
|
+
if (settingIds.has(setting.id)) {
|
|
226
|
+
throw new Error(`Schema: duplicate setting id '${setting.id}'`);
|
|
227
|
+
}
|
|
228
|
+
settingIds.add(setting.id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// (b) every setting placed in some tab
|
|
232
|
+
const referenced = new Set<string>();
|
|
233
|
+
for (const tab of schema.tabs) {
|
|
234
|
+
for (const id of tab.settingIds) referenced.add(id);
|
|
235
|
+
}
|
|
236
|
+
for (const id of settingIds) {
|
|
237
|
+
if (!referenced.has(id)) {
|
|
238
|
+
throw new Error(`Schema: setting '${id}' is not placed in any tab`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// (c) every tab.settingId references a defined setting
|
|
243
|
+
for (const tab of schema.tabs) {
|
|
244
|
+
for (const id of tab.settingIds) {
|
|
245
|
+
if (!settingIds.has(id)) {
|
|
246
|
+
throw new Error(`Schema: tab '${tab.label}' references unknown setting '${id}'`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Apply post-normalization clamps for settings with inter-field constraints
|
|
254
|
+
* that cannot be expressed per-field in the schema.
|
|
255
|
+
*/
|
|
256
|
+
export function clampSettings(
|
|
257
|
+
result: Record<string, unknown>,
|
|
258
|
+
clampFn: ((result: Record<string, unknown>) => void) | undefined,
|
|
259
|
+
): void {
|
|
260
|
+
clampFn?.(result);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get the effective value for a setting from raw data.
|
|
265
|
+
* Returns the default if the raw value is invalid.
|
|
266
|
+
*/
|
|
267
|
+
export function getEffectiveValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
|
|
268
|
+
return resolveValue(raw, setting);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compute the raw internal string value for a setting.
|
|
273
|
+
* Converts null to the appropriate sentinel string.
|
|
274
|
+
*/
|
|
275
|
+
export function computeRawInternalValue(settings: Record<string, unknown>, setting: SettingSchema): string {
|
|
276
|
+
const value = settings[setting.id];
|
|
277
|
+
if (value === null || value === undefined) {
|
|
278
|
+
// Display fallback only — the null label for nullable settings, or "Infinite" defensively
|
|
279
|
+
// for non-nullable settings that shouldn't hold null anyway.
|
|
280
|
+
return resolveNullLabel(setting) ?? "Infinite";
|
|
281
|
+
}
|
|
282
|
+
return String(value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Preset resolvers (single source: `presets` pairs, resolved via the effective seam) ──
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Per-setting cache of normalized static presets. `SettingSchema` refs are stable, so a WeakMap
|
|
289
|
+
* lets the gate resolve a setting's pairs once instead of re-normalizing (re-parsing bare
|
|
290
|
+
* strings) on every load. Populated eagerly by `createSettingsExtension` schema validation.
|
|
291
|
+
*/
|
|
292
|
+
const staticPresetsCache = new WeakMap<SettingSchema, PresetPair[]>();
|
|
293
|
+
|
|
294
|
+
/** Type guard: a `PresetElement` is a pair when it is an array (a bare value is never array-typed). */
|
|
295
|
+
function isPresetPair(el: PresetElement): el is PresetPair {
|
|
296
|
+
return Array.isArray(el);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Normalize a `PresetElement[]` (bare values OR full pairs, possibly MIXED) into `PresetPair[]`
|
|
301
|
+
* at the single resolution seam. Bare string → type-aware `[s, typeDef.parse(s, ctx)]` (duration
|
|
302
|
+
* "30m"→1800000ms, string→identity via an empty-presets ctx, custom types free); bare
|
|
303
|
+
* number/boolean/null → `[String(v), v]`; a full `PresetPair` → as-is. An unparseable bare string
|
|
304
|
+
* THROWS — caught at registration (createSettingsExtension schema validation → fail-fast) or by a
|
|
305
|
+
* resolver's degrade-to-empty for async sources. Downstream stays `PresetPair[]`-based.
|
|
306
|
+
*/
|
|
307
|
+
export function normalizePresetElements(elements: readonly PresetElement[], setting: SettingSchema): PresetPair[] {
|
|
308
|
+
const typeDef = getTypeDefinition(setting.type);
|
|
309
|
+
// Empty-presets ctx so the string type's closed-enum parse degrades to identity (a bare enum
|
|
310
|
+
// value like "high" becomes ["high","high"], not a membership-rejection). number/duration parses
|
|
311
|
+
// ignore presets; compact-threshold falls back to its numeric path.
|
|
312
|
+
const ctx: TypeContext = { presets: [], min: setting.min, max: setting.max };
|
|
313
|
+
const result: PresetPair[] = [];
|
|
314
|
+
for (const el of elements) {
|
|
315
|
+
if (isPresetPair(el)) {
|
|
316
|
+
result.push(el);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (typeof el === "string") {
|
|
320
|
+
const parsed = typeDef.parse(el, ctx);
|
|
321
|
+
if (parsed === undefined) {
|
|
322
|
+
throw new Error(`Setting '${setting.id}': preset '${el}' is not a valid ${setting.type}`);
|
|
323
|
+
}
|
|
324
|
+
result.push([el, parsed as PresetValue]);
|
|
325
|
+
} else {
|
|
326
|
+
// number | boolean | null → [String(v), v]
|
|
327
|
+
result.push([String(el), el]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the EFFECTIVE source of presets for a setting: `setting.presets ?? typeDef.presets`.
|
|
335
|
+
* This is the single source of truth for the override pick — the gate and the UI both go through
|
|
336
|
+
* it. A resolver-function source is flagged `isFunction: true` with empty `pairs` (the gate
|
|
337
|
+
* cannot call a resolver, so it treats it as no presets at load; the UI resolves it once at
|
|
338
|
+
* modal-open). A static-array source yields the pairs directly. Absent at both levels → empty.
|
|
339
|
+
*/
|
|
340
|
+
export function resolveEffectivePresets(setting: SettingSchema): {
|
|
341
|
+
isFunction: boolean;
|
|
342
|
+
pairs: PresetPair[];
|
|
343
|
+
} {
|
|
344
|
+
const src: PresetsSource | undefined = setting.presets ?? getTypeDefinition(setting.type).presets;
|
|
345
|
+
if (src === undefined) return { isFunction: false, pairs: [] };
|
|
346
|
+
if (typeof src === "function") return { isFunction: true, pairs: [] };
|
|
347
|
+
// Static array — normalize PresetElement[] → PresetPair[] once (cached on the stable schema ref).
|
|
348
|
+
// Validation at createSettingsExtension forces this normalization eagerly and throws on a bad
|
|
349
|
+
// bare string, so a runtime call never re-throws (the cache is already populated).
|
|
350
|
+
let cached = staticPresetsCache.get(setting);
|
|
351
|
+
if (!cached) {
|
|
352
|
+
cached = normalizePresetElements(src, setting);
|
|
353
|
+
staticPresetsCache.set(setting, cached);
|
|
354
|
+
}
|
|
355
|
+
return { isFunction: false, pairs: [...cached] };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the STATIC presets for a setting — the pairs the gate can use at load. Resolver-function
|
|
360
|
+
* sources collapse to `[]` here (resolved later by the UI); static-array sources yield their
|
|
361
|
+
* pairs. Thin wrapper over `resolveEffectivePresets` so call sites that only need the pairs keep
|
|
362
|
+
* a stable shape.
|
|
363
|
+
*/
|
|
364
|
+
export function resolveStaticPresets(setting: SettingSchema): PresetPair[] {
|
|
365
|
+
return resolveEffectivePresets(setting).pairs;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The display label for the null value — the label of the preset pair whose value is null.
|
|
370
|
+
* Returns `undefined` when the setting is NOT nullable (no null pair): callers use this to
|
|
371
|
+
* decide whether null handling applies at all, so a non-nullable timeout correctly rejects
|
|
372
|
+
* "Infinite". For a well-formed nullable setting, a null pair always exists, so the label
|
|
373
|
+
* is fully derived from `presets`. Reads the EFFECTIVE static presets (setting override OR
|
|
374
|
+
* type-def default); resolver-function sources correctly yield `undefined` at load (their null
|
|
375
|
+
* label is resolved from pairs at UI time).
|
|
376
|
+
*/
|
|
377
|
+
export function resolveNullLabel(setting: SettingSchema): string | undefined {
|
|
378
|
+
return resolveNullLabelFromPairs(resolveStaticPresets(setting));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Serialize a preset value (string | number | boolean | null) to its internal string form. */
|
|
382
|
+
function serializePresetValue(value: string | number | boolean | null, nullLabel: string): string {
|
|
383
|
+
return value === null ? nullLabel : String(value);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The display label for the null value, derived directly from a pairs array. Pure pair-level
|
|
388
|
+
* helper shared by `resolveNullLabel` (via `resolveStaticPresets`) and the UI's resolved-pairs
|
|
389
|
+
* path (a resolver's `[["Default", null], …]` is only available after resolution at open).
|
|
390
|
+
* Returns `undefined` when no pair has a null value.
|
|
391
|
+
*/
|
|
392
|
+
export function resolveNullLabelFromPairs(pairs: readonly PresetPair[]): string | undefined {
|
|
393
|
+
for (const [label, value] of pairs) {
|
|
394
|
+
if (value === null) return label;
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Build the full `PresetItem[]` (label + rawValue + displayValue) from a pairs array, in display
|
|
401
|
+
* order. Pure pair-level derivation shared by the gate (via `buildPresetItems(setting)`) and the
|
|
402
|
+
* UI's resolved-pairs path. The displayValue is the serialized string form (the null pair
|
|
403
|
+
* serializes to its label, or "Infinite" defensively when no null pair exists).
|
|
404
|
+
*/
|
|
405
|
+
export function buildPresetItemsFromPairs(pairs: readonly PresetPair[]): readonly PresetItem[] {
|
|
406
|
+
const nullLabel = resolveNullLabelFromPairs(pairs) ?? "Infinite";
|
|
407
|
+
return pairs.map(([label, value]) => ({
|
|
408
|
+
label,
|
|
409
|
+
rawValue: value,
|
|
410
|
+
displayValue: serializePresetValue(value, nullLabel),
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Build the full `PresetItem[]` for a setting from its EFFECTIVE static presets. Thin wrapper
|
|
416
|
+
* over `buildPresetItemsFromPairs(resolveStaticPresets(setting))` — kept for its existing tested
|
|
417
|
+
* signature. Resolver-function sources yield `[]` here (resolved by the UI at open).
|
|
418
|
+
*/
|
|
419
|
+
export function buildPresetItems(setting: SettingSchema): readonly PresetItem[] {
|
|
420
|
+
return buildPresetItemsFromPairs(resolveStaticPresets(setting));
|
|
421
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic settings persistence.
|
|
6
|
+
*
|
|
7
|
+
* Layered JSON file read/write with atomic writes.
|
|
8
|
+
* Uses schema for field names (no hardcoded per-field logic).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { normalizeFromSchema } from "./normalization.js";
|
|
15
|
+
import type { SettingsSchema } from "./schema.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standard settings file paths for an extension named `name`.
|
|
19
|
+
*
|
|
20
|
+
* Returns the `globalPath` / `projectPath` pair every settings-ui consumer uses,
|
|
21
|
+
* so all extensions follow ONE layout (no per-consumer hand-rolled path logic):
|
|
22
|
+
* global: <globalDir | ~/.pi>/agent/<name>-settings.json
|
|
23
|
+
* project: <cwd>/.pi/<name>-settings.json
|
|
24
|
+
*
|
|
25
|
+
* Uses `os.homedir()` + `path.join` (cross-platform correct); consumers must NOT
|
|
26
|
+
* hand-roll these with env-var string concatenation.
|
|
27
|
+
*/
|
|
28
|
+
export function settingsFilePaths(name: string): Pick<SettingsSchema, "globalPath" | "projectPath"> {
|
|
29
|
+
const filename = `${name}-settings.json`;
|
|
30
|
+
return {
|
|
31
|
+
globalPath: (globalDir?: string): string => join(globalDir ?? join(homedir(), ".pi"), "agent", filename),
|
|
32
|
+
projectPath: (cwd: string): string => join(cwd, ".pi", filename),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read and parse a JSON file, returning null on any error. */
|
|
37
|
+
export function readJsonFile(filePath: string): Record<string, unknown> | null {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(filePath)) return null;
|
|
40
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
41
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Atomically write JSON to a file via temp file + rename. */
|
|
48
|
+
export function atomicWriteJson(filePath: string, data: unknown): void {
|
|
49
|
+
const dir = dirname(filePath);
|
|
50
|
+
if (!existsSync(dir)) {
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
const tmp = `${filePath}.tmp`;
|
|
54
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
55
|
+
renameSync(tmp, filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load settings from layered files (global + project override).
|
|
60
|
+
* Uses schema for field names — no per-field switch needed.
|
|
61
|
+
*/
|
|
62
|
+
export function loadSettingsFromFiles(
|
|
63
|
+
cwd: string,
|
|
64
|
+
schema: SettingsSchema,
|
|
65
|
+
defaults: Record<string, unknown>,
|
|
66
|
+
globalDir: string | undefined,
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
const globalPath = schema.globalPath(globalDir);
|
|
69
|
+
const projectPath = schema.projectPath(cwd);
|
|
70
|
+
|
|
71
|
+
const globalRaw = readJsonFile(globalPath);
|
|
72
|
+
const projectRaw = readJsonFile(projectPath);
|
|
73
|
+
|
|
74
|
+
// Start with defaults, layer global, then project
|
|
75
|
+
let settings = { ...defaults };
|
|
76
|
+
|
|
77
|
+
if (globalRaw) {
|
|
78
|
+
const normalized = normalizeFromSchema(globalRaw, schema);
|
|
79
|
+
// Merge all normalized global values
|
|
80
|
+
settings = { ...settings, ...normalized };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (projectRaw) {
|
|
84
|
+
const normalized = normalizeFromSchema(projectRaw, schema);
|
|
85
|
+
// Only override keys that are explicitly present in the project file
|
|
86
|
+
for (const setting of schema.settings) {
|
|
87
|
+
const present = setting.id in projectRaw || setting.aliases?.some((a) => a in projectRaw);
|
|
88
|
+
if (present) {
|
|
89
|
+
settings[setting.id] = normalized[setting.id];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return settings;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Save settings to global file.
|
|
99
|
+
*/
|
|
100
|
+
export function saveGlobalSettings(
|
|
101
|
+
settings: Record<string, unknown>,
|
|
102
|
+
schema: SettingsSchema,
|
|
103
|
+
globalDir: string | undefined,
|
|
104
|
+
): void {
|
|
105
|
+
const filePath = schema.globalPath(globalDir);
|
|
106
|
+
atomicWriteJson(filePath, settings);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Save settings to project file.
|
|
111
|
+
*/
|
|
112
|
+
export function saveProjectSettings(settings: Record<string, unknown>, cwd: string, schema: SettingsSchema): void {
|
|
113
|
+
const filePath = schema.projectPath(cwd);
|
|
114
|
+
atomicWriteJson(filePath, settings);
|
|
115
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UI-time batch resolution of function-`presets` sources.
|
|
6
|
+
*
|
|
7
|
+
* A setting whose effective `presets` is a function (the model resolver) is resolved
|
|
8
|
+
* once, at modal-open, into a map of pairs keyed by setting id. The map is threaded
|
|
9
|
+
* into `buildSchemaTabGroups` so the UI renders the resolved options. Resolvers take
|
|
10
|
+
* no arguments — they read captured context themselves. A resolver that throws or
|
|
11
|
+
* rejects degrades to an empty list, so one failing resolver never breaks the batch.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { normalizePresetElements, resolveEffectivePresets } from "./normalization.js";
|
|
15
|
+
import type { PresetElement, PresetPair, SettingSchema, SettingsSchema } from "./schema.js";
|
|
16
|
+
import { getTypeDefinition } from "./type-definitions.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The pairs substituted when a resolver throws or rejects. A named const (not a bare
|
|
20
|
+
* `[]`) so the element type is carried: a bare `[]` in `.catch(() => [])` infers
|
|
21
|
+
* `never[]`, which won't widen to `PresetPair[]` for the resolved-entry tuple.
|
|
22
|
+
*/
|
|
23
|
+
const EMPTY_PRESET_PAIRS: readonly PresetPair[] = [];
|
|
24
|
+
|
|
25
|
+
/** Pick the effective presets source (setting override OR type-def default) as a function. */
|
|
26
|
+
function getResolverFn(setting: SettingSchema): () => PresetElement[] | Promise<PresetElement[]> {
|
|
27
|
+
const src = setting.presets ?? getTypeDefinition(setting.type).presets;
|
|
28
|
+
return src as () => PresetElement[] | Promise<PresetElement[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Result of resolving a schema's function-presets: the resolved pairs plus any that failed. */
|
|
32
|
+
export interface ResolvedPresets {
|
|
33
|
+
/** Setting id → resolved preset pairs (empty list for a setting whose resolver failed). */
|
|
34
|
+
pairs: Map<string, PresetPair[]>;
|
|
35
|
+
/** Ids of settings whose resolver threw or rejected. Caller surfaces these to the user. */
|
|
36
|
+
failedIds: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve every function-`presets` setting in the schema.
|
|
41
|
+
*
|
|
42
|
+
* Static-array presets are ignored here (they stay WeakMap-cached in the tab builder);
|
|
43
|
+
* only function sources are resolved. Each resolver is invoked with no arguments and
|
|
44
|
+
* guarded so a synchronous throw or an async rejection becomes an empty pairs list —
|
|
45
|
+
* one failing resolver never breaks the batch. Failing setting ids are collected in
|
|
46
|
+
* `failedIds` so the caller can warn the user (the modal still opens with defaults).
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveFunctionPresets(schema: SettingsSchema): Promise<ResolvedPresets> {
|
|
49
|
+
const resolverSettings = schema.settings.filter((s) => resolveEffectivePresets(s).isFunction);
|
|
50
|
+
const failedIds: string[] = [];
|
|
51
|
+
const entries = await Promise.all(
|
|
52
|
+
resolverSettings.map((s) =>
|
|
53
|
+
Promise.resolve()
|
|
54
|
+
.then(async () => normalizePresetElements(await getResolverFn(s)(), s))
|
|
55
|
+
.catch(() => {
|
|
56
|
+
// Degrade to an empty list so the batch (and the modal) survive — covers both a resolver
|
|
57
|
+
// that throws/rejects AND a bad bare string in its result (normalizePresetElements throws).
|
|
58
|
+
// Record the id so the caller can tell the user this setting's options could not load.
|
|
59
|
+
failedIds.push(s.id);
|
|
60
|
+
return EMPTY_PRESET_PAIRS;
|
|
61
|
+
})
|
|
62
|
+
.then((pairs): readonly [string, PresetPair[]] => [s.id, [...pairs]]),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
return { pairs: new Map(entries), failedIds };
|
|
66
|
+
}
|