@promptctl/cc-candybar 1.30.0 → 1.32.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/dist/index.mjs +80 -80
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +65 -0
- package/src/click/wire.ts +9 -0
- package/src/config/action.ts +65 -2
- package/src/config/dsl-loader.ts +18 -1
- package/src/config/edit-chrome.ts +329 -0
- package/src/config/layout-ops.ts +21 -0
- package/src/config/loader/actions.ts +97 -13
- package/src/config/loader/cross-ref.ts +29 -9
- package/src/config/loader/edit-mode.ts +111 -0
- package/src/daemon/config-overrides-store.ts +308 -17
- package/src/daemon/verbs/config-validators.ts +39 -1
- package/src/daemon/verbs/index.ts +40 -2
- package/src/dsl/render.ts +10 -1
- package/src/render/action.ts +76 -2
- package/src/render/picker.ts +71 -38
|
@@ -322,43 +322,94 @@ export function loadOverrides(
|
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
// [LAW:no-silent-failure]
|
|
326
|
-
//
|
|
327
|
-
//
|
|
325
|
+
// [LAW:no-silent-failure] The atomic write/rename dance, generalized over ANY
|
|
326
|
+
// JSON-serializable value — both this module's flat overrides dict and its
|
|
327
|
+
// history stack (below) go through this one primitive rather than each
|
|
328
|
+
// re-implementing mkdir+tmp+chmod+rename. Owner-only mode, matching every
|
|
329
|
+
// other daemon runtime file (session-state.json, pid, lease). `label` names
|
|
330
|
+
// the failure in the log/thrown message (the caller's own vocabulary —
|
|
331
|
+
// "config-overrides"/"config-overrides-history" — not derived from the path,
|
|
332
|
+
// so the wording a test might match on stays stable across either file).
|
|
328
333
|
// Unlike session-state.json's debounced best-effort flush (no synchronous
|
|
329
|
-
// caller waiting on it), a `persist` write is directly caused
|
|
330
|
-
// expects a truthful ack — a swallowed failure here would let
|
|
331
|
-
// handler log
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
function
|
|
334
|
+
// caller waiting on it), a `persist`/`undo`/`redo` write is directly caused
|
|
335
|
+
// by a click that expects a truthful ack — a swallowed failure here would let
|
|
336
|
+
// the verb handler log success for a write that didn't land. Logs at "error"
|
|
337
|
+
// for the daemon-log breadcrumb, then RETHROWS so the caller (the click)
|
|
338
|
+
// fails loudly instead of claiming a success that didn't happen.
|
|
339
|
+
function writeJsonAtomic(
|
|
335
340
|
filePath: string,
|
|
336
|
-
|
|
341
|
+
label: string,
|
|
342
|
+
value: unknown,
|
|
337
343
|
logger: DaemonLogger,
|
|
338
344
|
): void {
|
|
339
345
|
try {
|
|
340
346
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
341
347
|
const tmp = `${filePath}.tmp`;
|
|
342
|
-
fs.writeFileSync(tmp, JSON.stringify(
|
|
348
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
343
349
|
fs.chmodSync(tmp, 0o600);
|
|
344
350
|
fs.renameSync(tmp, filePath);
|
|
345
351
|
} catch (e) {
|
|
346
|
-
const message =
|
|
352
|
+
const message = `${label} write failed: ${(e as Error).message}`;
|
|
347
353
|
logger("error", message);
|
|
348
354
|
throw new Error(message);
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
357
|
|
|
358
|
+
function writeOverrides(
|
|
359
|
+
filePath: string,
|
|
360
|
+
overrides: Readonly<Record<string, string | number | boolean>>,
|
|
361
|
+
logger: DaemonLogger,
|
|
362
|
+
): void {
|
|
363
|
+
writeJsonAtomic(filePath, "config-overrides", overrides, logger);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// [LAW:one-source-of-truth] The one place a key's value in the flat dict
|
|
367
|
+
// changes (set-or-delete) — writeConfigOverride/clearConfigOverride/
|
|
368
|
+
// restoreConfigOverrideValue all fold through here, so "what was the value
|
|
369
|
+
// BEFORE this write" (the fact history needs) is captured at the one site
|
|
370
|
+
// that reads-then-writes it, never re-derived. `value: undefined` deletes;
|
|
371
|
+
// any other value sets. Returns the previous value (or undefined if the key
|
|
372
|
+
// was absent) — the caller decides whether that fact matters.
|
|
373
|
+
function mutateOverride(
|
|
374
|
+
filePath: string,
|
|
375
|
+
key: string,
|
|
376
|
+
value: string | number | boolean | undefined,
|
|
377
|
+
logger: DaemonLogger,
|
|
378
|
+
): string | number | boolean | undefined {
|
|
379
|
+
const overrides = loadRawOverrides(filePath, logger);
|
|
380
|
+
const prev = overrides[key];
|
|
381
|
+
if (value === undefined) {
|
|
382
|
+
if (!(key in overrides)) return prev;
|
|
383
|
+
const next = { ...overrides };
|
|
384
|
+
delete next[key];
|
|
385
|
+
writeOverrides(filePath, next, logger);
|
|
386
|
+
} else {
|
|
387
|
+
writeOverrides(filePath, { ...overrides, [key]: value }, logger);
|
|
388
|
+
}
|
|
389
|
+
return prev;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// [LAW:one-source-of-truth] `persist`'s write, TRACKED: mutate the key, then
|
|
393
|
+
// record the transition on the SAME global history undo/redo step
|
|
394
|
+
// (brandon-layout-edit-2gc.2). This is the ONE enforcement point — every
|
|
395
|
+
// current and future caller of writeConfigOverride (setConfig, stepConfig,
|
|
396
|
+
// apply-layout-op's append) gets history for free, with zero edits to those
|
|
397
|
+
// verb handlers, because the recording lives here rather than at each call
|
|
398
|
+
// site. [LAW:locality-or-seam]
|
|
352
399
|
export function writeConfigOverride(
|
|
353
400
|
filePath: string,
|
|
354
401
|
key: string,
|
|
355
402
|
value: string | number | boolean,
|
|
356
403
|
logger: DaemonLogger = quietLogger,
|
|
357
404
|
): void {
|
|
358
|
-
const
|
|
359
|
-
|
|
405
|
+
const prev = mutateOverride(filePath, key, value, logger);
|
|
406
|
+
pushHistoryEntry(filePath, { key, from: prev ?? null, to: value }, logger);
|
|
360
407
|
}
|
|
361
408
|
|
|
409
|
+
// [LAW:one-source-of-truth] `reset`'s write, TRACKED — mirrors
|
|
410
|
+
// writeConfigOverride above. A clear that touches nothing (the key was
|
|
411
|
+
// already absent) records no entry: nothing changed, so there is nothing to
|
|
412
|
+
// undo back to.
|
|
362
413
|
export function clearConfigOverride(
|
|
363
414
|
filePath: string,
|
|
364
415
|
key: string,
|
|
@@ -366,7 +417,247 @@ export function clearConfigOverride(
|
|
|
366
417
|
): void {
|
|
367
418
|
const overrides = loadRawOverrides(filePath, logger);
|
|
368
419
|
if (!(key in overrides)) return;
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
420
|
+
const prev = mutateOverride(filePath, key, undefined, logger);
|
|
421
|
+
pushHistoryEntry(filePath, { key, from: prev ?? null, to: null }, logger);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// [LAW:one-source-of-truth] The UNTRACKED twin — restores a key to EXACTLY
|
|
425
|
+
// `value` (or clears it, for `null`) without recording a new history entry.
|
|
426
|
+
// The only legitimate callers are popPastEntry/popFutureEntry below: undo and
|
|
427
|
+
// redo already know they're moving an entry between the past/future stacks,
|
|
428
|
+
// so routing their own restoration back through the tracked writers would
|
|
429
|
+
// record the undo/redo AS a new forward edit — burying the entry it just
|
|
430
|
+
// popped and making the OTHER stack unreachable. This is a structurally
|
|
431
|
+
// distinct function, not a boolean flag on the tracked ones
|
|
432
|
+
// [LAW:no-mode-explosion] — its contract ("apply this exact value, no
|
|
433
|
+
// bookkeeping") is different from theirs ("write this value, remember how to
|
|
434
|
+
// undo it"), not a variant of the same one.
|
|
435
|
+
function restoreConfigOverrideValue(
|
|
436
|
+
filePath: string,
|
|
437
|
+
key: string,
|
|
438
|
+
value: string | number | boolean | null,
|
|
439
|
+
logger: DaemonLogger,
|
|
440
|
+
): void {
|
|
441
|
+
mutateOverride(filePath, key, value === null ? undefined : value, logger);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Undo/redo history (brandon-layout-edit-2gc.2) ────────────────────────
|
|
445
|
+
|
|
446
|
+
// [LAW:types-are-the-program] ONE entry shape covers every scope the
|
|
447
|
+
// overrides file holds — a globals field's snapshot overwrite (setConfig), a
|
|
448
|
+
// segment-palette snapshot overwrite (same verb, different key shape), AND a
|
|
449
|
+
// preset-root-ops APPEND (apply-layout-op's read-current-append-write) —
|
|
450
|
+
// because at the STORAGE layer every one of those is indistinguishable from
|
|
451
|
+
// "the value at `key` changed from `from` to `to`". apply-layout-op computes
|
|
452
|
+
// its new array-of-tokens string by reading-then-appending one level up
|
|
453
|
+
// (verbs/index.ts); by the time that string reaches writeConfigOverride, it
|
|
454
|
+
// is just the next value at that key. Undo restoring `from` verbatim is
|
|
455
|
+
// therefore ALSO the correct "pop the last op token" behavior for a rootOps
|
|
456
|
+
// key, with no rootOps-specific code anywhere in this module — the ticket's
|
|
457
|
+
// "one history over the overrides layer, not a layout-specific feature" falls
|
|
458
|
+
// out of the shape, it isn't special-cased into it. `null` is the ABSENT
|
|
459
|
+
// sentinel (a key with no prior/no resulting value): safe because no real
|
|
460
|
+
// override value is ever `null` — see isValidOverrides's kind table.
|
|
461
|
+
export interface HistoryEntry {
|
|
462
|
+
readonly key: string;
|
|
463
|
+
readonly from: string | number | boolean | null;
|
|
464
|
+
readonly to: string | number | boolean | null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface HistoryState {
|
|
468
|
+
readonly past: readonly HistoryEntry[];
|
|
469
|
+
readonly future: readonly HistoryEntry[];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const EMPTY_HISTORY: HistoryState = { past: [], future: [] };
|
|
473
|
+
|
|
474
|
+
// [LAW:carrying-cost] Resolves the ticket's "depth of the ring" question:
|
|
475
|
+
// bounded so a long-running daemon's history file cannot grow without limit,
|
|
476
|
+
// generous enough that no realistic editing session bumps into it. Oldest
|
|
477
|
+
// entries fall off first (capPush below) — a silent, documented trim, not a
|
|
478
|
+
// failure.
|
|
479
|
+
const MAX_HISTORY_DEPTH = 50;
|
|
480
|
+
|
|
481
|
+
// [LAW:one-source-of-truth] Resolves the ticket's "where it lives relative to
|
|
482
|
+
// the overrides file" question: a SIBLING file in the same directory, derived
|
|
483
|
+
// as a pure function of the overrides path already passed in — no reach to
|
|
484
|
+
// paths.ts/global state, so every existing call site (and every existing
|
|
485
|
+
// test's XDG_STATE_HOME isolation, which already isolates configOverridesPath())
|
|
486
|
+
// isolates this file too, with zero additional test-harness surface. Kept
|
|
487
|
+
// SEPARATE from the overrides file itself (rather than nesting it inside a
|
|
488
|
+
// wrapper shape) so the overrides file's own on-disk shape — asserted by
|
|
489
|
+
// name in existing tests and callers — never changes
|
|
490
|
+
// [LAW:locality-or-seam]: a change to history storage must not ripple into
|
|
491
|
+
// every existing reader of the flat overrides dict.
|
|
492
|
+
function historyPathFor(overridesFilePath: string): string {
|
|
493
|
+
return path.join(
|
|
494
|
+
path.dirname(overridesFilePath),
|
|
495
|
+
"config-overrides-history.json",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isValidHistoryValue(
|
|
500
|
+
v: unknown,
|
|
501
|
+
): v is string | number | boolean | null {
|
|
502
|
+
return (
|
|
503
|
+
v === null ||
|
|
504
|
+
typeof v === "string" ||
|
|
505
|
+
typeof v === "number" ||
|
|
506
|
+
typeof v === "boolean"
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isValidHistoryEntry(v: unknown): v is HistoryEntry {
|
|
511
|
+
if (v === null || typeof v !== "object") return false;
|
|
512
|
+
const obj = v as Record<string, unknown>;
|
|
513
|
+
return (
|
|
514
|
+
typeof obj.key === "string" &&
|
|
515
|
+
isValidHistoryValue(obj.from) &&
|
|
516
|
+
isValidHistoryValue(obj.to)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
|
|
521
|
+
// history is the DEFINED recovery (mirrors isValidOverrides/loadRawOverrides'
|
|
522
|
+
// identical "first-ever boot" treatment for the sibling file) — a single
|
|
523
|
+
// malformed entry drops the WHOLE history, never a guess at which entries to
|
|
524
|
+
// salvage.
|
|
525
|
+
function isValidHistoryState(v: unknown): v is HistoryState {
|
|
526
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
527
|
+
const obj = v as Record<string, unknown>;
|
|
528
|
+
return (
|
|
529
|
+
Array.isArray(obj.past) &&
|
|
530
|
+
obj.past.every(isValidHistoryEntry) &&
|
|
531
|
+
Array.isArray(obj.future) &&
|
|
532
|
+
obj.future.every(isValidHistoryEntry)
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function loadHistoryState(
|
|
537
|
+
overridesFilePath: string,
|
|
538
|
+
logger: DaemonLogger,
|
|
539
|
+
): HistoryState {
|
|
540
|
+
const filePath = historyPathFor(overridesFilePath);
|
|
541
|
+
let raw: string;
|
|
542
|
+
try {
|
|
543
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
544
|
+
} catch (e) {
|
|
545
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
546
|
+
if (code !== "ENOENT") {
|
|
547
|
+
logger(
|
|
548
|
+
"warn",
|
|
549
|
+
`config-overrides-history read failed (${code}); starting empty`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return EMPTY_HISTORY;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const parsed: unknown = JSON.parse(raw);
|
|
556
|
+
if (isValidHistoryState(parsed)) return parsed;
|
|
557
|
+
logger(
|
|
558
|
+
"warn",
|
|
559
|
+
`config-overrides-history load: unexpected shape, starting empty`,
|
|
560
|
+
);
|
|
561
|
+
return EMPTY_HISTORY;
|
|
562
|
+
} catch {
|
|
563
|
+
logger(
|
|
564
|
+
"warn",
|
|
565
|
+
`config-overrides-history load: corrupt JSON, starting empty`,
|
|
566
|
+
);
|
|
567
|
+
return EMPTY_HISTORY;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function writeHistoryState(
|
|
572
|
+
overridesFilePath: string,
|
|
573
|
+
state: HistoryState,
|
|
574
|
+
logger: DaemonLogger,
|
|
575
|
+
): void {
|
|
576
|
+
writeJsonAtomic(
|
|
577
|
+
historyPathFor(overridesFilePath),
|
|
578
|
+
"config-overrides-history",
|
|
579
|
+
state,
|
|
580
|
+
logger,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// [LAW:no-mode-explosion] Bounded push, oldest-drops-first, shared by both
|
|
585
|
+
// stacks (past grows on a fresh edit or a redo; future grows on an undo) —
|
|
586
|
+
// one shape, not two near-duplicate arms.
|
|
587
|
+
function capPush<T>(arr: readonly T[], entry: T, max: number): readonly T[] {
|
|
588
|
+
const next = [...arr, entry];
|
|
589
|
+
return next.length > max ? next.slice(next.length - max) : next;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// [LAW:one-source-of-truth] The ONLY caller is writeConfigOverride/
|
|
593
|
+
// clearConfigOverride above — every tracked write lands here, so recording
|
|
594
|
+
// cannot drift from mutation. A fresh edit TRUNCATES `future`: the classic
|
|
595
|
+
// undo/redo branch — diverging from history by doing something NEW abandons
|
|
596
|
+
// whatever was undone, rather than silently keeping it reachable from a
|
|
597
|
+
// history state the new edit has already invalidated.
|
|
598
|
+
function pushHistoryEntry(
|
|
599
|
+
overridesFilePath: string,
|
|
600
|
+
entry: HistoryEntry,
|
|
601
|
+
logger: DaemonLogger,
|
|
602
|
+
): void {
|
|
603
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
604
|
+
writeHistoryState(
|
|
605
|
+
overridesFilePath,
|
|
606
|
+
{ past: capPush(state.past, entry, MAX_HISTORY_DEPTH), future: [] },
|
|
607
|
+
logger,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// [LAW:one-source-of-truth] The daemon-GLOBAL history is ONE stack, not
|
|
612
|
+
// per-session: config-overrides.json already has exactly one writer (the
|
|
613
|
+
// daemon) and no session-scoping (candybar-config-engine-71o's own binding
|
|
614
|
+
// guardrail — a `persist` write is daemon-global by design), so undo/redo
|
|
615
|
+
// stepping that SAME single-writer file inherits the same scope rather than
|
|
616
|
+
// inventing a session axis the storage layer doesn't otherwise have. Two
|
|
617
|
+
// sessions clicking undo do see each other's edits — a real, DELIBERATE
|
|
618
|
+
// consequence of there being one bar default, not a bug: the alternative
|
|
619
|
+
// (per-session history over daemon-global state) would let one session's
|
|
620
|
+
// "undo" silently fail to undo what another session's click actually did.
|
|
621
|
+
//
|
|
622
|
+
// [LAW:no-silent-failure] Returns `null` at the bottom of the stack — the
|
|
623
|
+
// verb handler (verbs/index.ts) turns that into a loud BadVerbArgs surfaced
|
|
624
|
+
// through click.error, never a silent no-op.
|
|
625
|
+
export function undoLastOverride(
|
|
626
|
+
overridesFilePath: string,
|
|
627
|
+
logger: DaemonLogger = quietLogger,
|
|
628
|
+
): HistoryEntry | null {
|
|
629
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
630
|
+
const entry = state.past[state.past.length - 1];
|
|
631
|
+
if (entry === undefined) return null;
|
|
632
|
+
restoreConfigOverrideValue(overridesFilePath, entry.key, entry.from, logger);
|
|
633
|
+
writeHistoryState(
|
|
634
|
+
overridesFilePath,
|
|
635
|
+
{
|
|
636
|
+
past: state.past.slice(0, -1),
|
|
637
|
+
future: capPush(state.future, entry, MAX_HISTORY_DEPTH),
|
|
638
|
+
},
|
|
639
|
+
logger,
|
|
640
|
+
);
|
|
641
|
+
return entry;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// [LAW:no-silent-failure] Redo's mirror of undo above — `null` at the top of
|
|
645
|
+
// the stack, same loud surfacing contract.
|
|
646
|
+
export function redoLastOverride(
|
|
647
|
+
overridesFilePath: string,
|
|
648
|
+
logger: DaemonLogger = quietLogger,
|
|
649
|
+
): HistoryEntry | null {
|
|
650
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
651
|
+
const entry = state.future[state.future.length - 1];
|
|
652
|
+
if (entry === undefined) return null;
|
|
653
|
+
restoreConfigOverrideValue(overridesFilePath, entry.key, entry.to, logger);
|
|
654
|
+
writeHistoryState(
|
|
655
|
+
overridesFilePath,
|
|
656
|
+
{
|
|
657
|
+
past: capPush(state.past, entry, MAX_HISTORY_DEPTH),
|
|
658
|
+
future: state.future.slice(0, -1),
|
|
659
|
+
},
|
|
660
|
+
logger,
|
|
661
|
+
);
|
|
662
|
+
return entry;
|
|
372
663
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
perConfigDomainsFor,
|
|
16
16
|
resolveOptionDomain,
|
|
17
17
|
} from "../../config/option-domain";
|
|
18
|
+
import { addableSegmentDomains } from "../../config/edit-chrome";
|
|
18
19
|
import type { DslConfig } from "../../config/dsl-types";
|
|
19
20
|
import { isGlobalsField } from "../config-overrides-store";
|
|
20
21
|
import { encodeLayoutOp } from "../../config/layout-ops";
|
|
@@ -111,6 +112,34 @@ function actionKeySpecs(
|
|
|
111
112
|
},
|
|
112
113
|
];
|
|
113
114
|
}
|
|
115
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
116
|
+
// sibling: the allow-list is the ENCODED op token for every domain member,
|
|
117
|
+
// not the raw member — mirroring how a literal `insertSegment` contributes
|
|
118
|
+
// its own single encoded token above. A click carrying an option this
|
|
119
|
+
// domain never named — or naming a real segment but the wrong anchor/
|
|
120
|
+
// relation — cannot decode to a member of this list, so it is rejected the
|
|
121
|
+
// same loud way an unknown literal op token already is.
|
|
122
|
+
if ("insertSegmentFrom" in a) {
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
key: a.persist,
|
|
126
|
+
spec: {
|
|
127
|
+
kind: "allow-list",
|
|
128
|
+
allowed: resolveOptionDomain(
|
|
129
|
+
a.insertSegmentFrom,
|
|
130
|
+
perConfigDomains,
|
|
131
|
+
).map((segment) =>
|
|
132
|
+
encodeLayoutOp({
|
|
133
|
+
op: "insert",
|
|
134
|
+
segment,
|
|
135
|
+
anchor: a.anchor,
|
|
136
|
+
relation: a.relation,
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
114
143
|
return [
|
|
115
144
|
{
|
|
116
145
|
key: a.persist,
|
|
@@ -141,7 +170,16 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
141
170
|
|
|
142
171
|
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
143
172
|
const seeds = configKeySeeds(config);
|
|
144
|
-
|
|
173
|
+
// [LAW:one-source-of-truth] The "addable segment" domains
|
|
174
|
+
// (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
|
|
175
|
+
// looks/presets — the same per-preset seam `insertSegmentFrom` resolves
|
|
176
|
+
// through at render (render.ts's registerDslConfig merges the identical
|
|
177
|
+
// map), so the rendered picker options and the derived click gate can
|
|
178
|
+
// never diverge over what's addable.
|
|
179
|
+
const perConfigDomains = new Map([
|
|
180
|
+
...perConfigDomainsFor(config),
|
|
181
|
+
...addableSegmentDomains(config),
|
|
182
|
+
]);
|
|
145
183
|
return Object.values(config.actions).flatMap((a) =>
|
|
146
184
|
actionKeySpecs(a, seeds, perConfigDomains),
|
|
147
185
|
);
|
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
isGlobalsField,
|
|
38
38
|
loadConfigOverrides,
|
|
39
39
|
loadOverrides,
|
|
40
|
+
redoLastOverride,
|
|
41
|
+
undoLastOverride,
|
|
40
42
|
writeConfigOverride,
|
|
41
43
|
} from "../config-overrides-store";
|
|
42
44
|
import { configOverridesPath } from "../paths";
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
VERB_DISPATCH,
|
|
50
52
|
VERB_OPEN_VSCODE,
|
|
51
53
|
VERB_LOAD_CONFIG,
|
|
54
|
+
VERB_REDO,
|
|
52
55
|
VERB_RESET_CONFIG,
|
|
53
56
|
VERB_SET_CONFIG,
|
|
54
57
|
VERB_SET_STATE,
|
|
@@ -57,6 +60,7 @@ import {
|
|
|
57
60
|
VERB_SHOW_CONFIG_ERROR,
|
|
58
61
|
VERB_SHOW_CONFIG_WARNING,
|
|
59
62
|
VERB_TOOLBAR_TOGGLE,
|
|
63
|
+
VERB_UNDO,
|
|
60
64
|
} from "../../click/wire";
|
|
61
65
|
|
|
62
66
|
export interface VerbContext {
|
|
@@ -489,6 +493,35 @@ const applyLayoutOp: VerbHandler = (rawValue, ctx) => {
|
|
|
489
493
|
);
|
|
490
494
|
};
|
|
491
495
|
|
|
496
|
+
// [LAW:one-source-of-truth] `reset`'s fine-grained sibling: step the ONE
|
|
497
|
+
// global history over the overrides layer back one entry. No key, no value —
|
|
498
|
+
// the history store (config-overrides-store.ts) owns which entry moves and
|
|
499
|
+
// what it restores; this handler is pure plumbing between the wire and it.
|
|
500
|
+
// [LAW:no-silent-failure] An empty stack is a loud BAD_REQUEST (dispatch's
|
|
501
|
+
// aggregator turns it into a transient click.error), never a silent no-op —
|
|
502
|
+
// the ticket's own done-gate.
|
|
503
|
+
const undoConfig: VerbHandler = (value, ctx) => {
|
|
504
|
+
const [sessionId = ""] = decodeWire(() => decodeSegments(value));
|
|
505
|
+
const sid = requireSessionId(sessionId);
|
|
506
|
+
const entry = undoLastOverride(configOverridesPath(), ctx.dlog);
|
|
507
|
+
if (entry === null) {
|
|
508
|
+
throw new BadVerbArgs("undo: history is empty, nothing to undo");
|
|
509
|
+
}
|
|
510
|
+
ctx.dlog("info", `undo: ${entry.key} (session=${sid})`);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// [LAW:one-source-of-truth] undo's mirror — steps the same global history
|
|
514
|
+
// forward one entry.
|
|
515
|
+
const redoConfig: VerbHandler = (value, ctx) => {
|
|
516
|
+
const [sessionId = ""] = decodeWire(() => decodeSegments(value));
|
|
517
|
+
const sid = requireSessionId(sessionId);
|
|
518
|
+
const entry = redoLastOverride(configOverridesPath(), ctx.dlog);
|
|
519
|
+
if (entry === null) {
|
|
520
|
+
throw new BadVerbArgs("redo: nothing to redo");
|
|
521
|
+
}
|
|
522
|
+
ctx.dlog("info", `redo: ${entry.key} (session=${sid})`);
|
|
523
|
+
};
|
|
524
|
+
|
|
492
525
|
// ─── Registry ───────────────────────────────────────────────────────────────
|
|
493
526
|
|
|
494
527
|
// [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
|
|
@@ -551,6 +584,8 @@ const LEAF_VERBS = new Map<string, VerbHandler>([
|
|
|
551
584
|
[VERB_STEP_CONFIG, stepConfig],
|
|
552
585
|
[VERB_RESET_CONFIG, resetConfig],
|
|
553
586
|
[VERB_APPLY_LAYOUT_OP, applyLayoutOp],
|
|
587
|
+
[VERB_UNDO, undoConfig],
|
|
588
|
+
[VERB_REDO, redoConfig],
|
|
554
589
|
[VERB_SHOW_CONFIG_ERROR, showConfigError],
|
|
555
590
|
[VERB_SHOW_CONFIG_WARNING, showConfigWarning],
|
|
556
591
|
[VERB_TOOLBAR_TOGGLE, toolbarToggle],
|
|
@@ -583,8 +618,9 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
|
|
|
583
618
|
for (const { verb, value } of parseEffects(rawValue)) {
|
|
584
619
|
// Extract session ID from the first session-bearing effect for error display.
|
|
585
620
|
// set-state, step-state, set-config, step-config, reset-config,
|
|
586
|
-
// apply-layout-op, and toolbar-toggle all carry the session id
|
|
587
|
-
// first segment, so a failing step surfaces in the bar like any
|
|
621
|
+
// apply-layout-op, undo, redo, and toolbar-toggle all carry the session id
|
|
622
|
+
// as their first segment, so a failing step surfaces in the bar like any
|
|
623
|
+
// other.
|
|
588
624
|
if (
|
|
589
625
|
!sessionId &&
|
|
590
626
|
(verb === VERB_SET_STATE ||
|
|
@@ -593,6 +629,8 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
|
|
|
593
629
|
verb === VERB_STEP_CONFIG ||
|
|
594
630
|
verb === VERB_RESET_CONFIG ||
|
|
595
631
|
verb === VERB_APPLY_LAYOUT_OP ||
|
|
632
|
+
verb === VERB_UNDO ||
|
|
633
|
+
verb === VERB_REDO ||
|
|
596
634
|
verb === VERB_TOOLBAR_TOGGLE)
|
|
597
635
|
) {
|
|
598
636
|
const parts = decodeSegments(value);
|
package/src/dsl/render.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { HUE_STEP_VAR } from "../config/dsl-types.js";
|
|
24
24
|
import { perConfigDomainsFor } from "../config/option-domain.js";
|
|
25
25
|
import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
|
|
26
|
+
import { addableSegmentDomains } from "../config/edit-chrome.js";
|
|
26
27
|
import type { VariableStore } from "../var-system/store.js";
|
|
27
28
|
import type { SourceRegistry } from "../var-system/sources.js";
|
|
28
29
|
import {
|
|
@@ -340,7 +341,15 @@ export function registerDslConfig(
|
|
|
340
341
|
// one source.
|
|
341
342
|
const lookNames = Object.keys(config.looks);
|
|
342
343
|
const presetOptions = presetNames(config.presets);
|
|
343
|
-
|
|
344
|
+
// [LAW:one-source-of-truth] The "addable segment" per-preset domains merge
|
|
345
|
+
// in here — the SAME map config-validators.ts's deriveConfigActionValidators
|
|
346
|
+
// merges — so a synthesized `insertSegmentFrom` action's rendered options
|
|
347
|
+
// and its derived click gate resolve from one source, never two
|
|
348
|
+
// independently-computed sets.
|
|
349
|
+
const perConfigDomains = new Map([
|
|
350
|
+
...perConfigDomainsFor(config),
|
|
351
|
+
...addableSegmentDomains(config),
|
|
352
|
+
]);
|
|
344
353
|
const engine = createCcCandybarEngine(
|
|
345
354
|
{
|
|
346
355
|
...actionFuncs(actionRuntime),
|
package/src/render/action.ts
CHANGED
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
VERB_APPLY_LAYOUT_OP,
|
|
35
35
|
VERB_COPY,
|
|
36
36
|
VERB_OPEN_VSCODE,
|
|
37
|
+
VERB_REDO,
|
|
37
38
|
VERB_RESET_CONFIG,
|
|
38
39
|
VERB_SET_CONFIG,
|
|
39
40
|
VERB_SET_STATE,
|
|
40
41
|
VERB_STEP_CONFIG,
|
|
41
42
|
VERB_STEP_STATE,
|
|
43
|
+
VERB_UNDO,
|
|
42
44
|
type Effect,
|
|
43
45
|
} from "../click/wire.js";
|
|
44
46
|
|
|
@@ -142,7 +144,29 @@ export type CompiledActionDecl =
|
|
|
142
144
|
// arms. Fully literal at compile time (the op IS the declaration — no
|
|
143
145
|
// template-bound option, unlike persist-option), so `op` is precomputed
|
|
144
146
|
// here rather than reconstructed from raw fields at every realize() call.
|
|
145
|
-
| { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp }
|
|
147
|
+
| { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp }
|
|
148
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
149
|
+
// sibling of layout-op: `anchor`/`relation` are fixed at compile time (the
|
|
150
|
+
// POSITION is author-time data) but the segment name comes from the
|
|
151
|
+
// template's bound option — the option-picking shape `persist-option`
|
|
152
|
+
// already has, minus the value being written VERBATIM. `requireOptionKind`
|
|
153
|
+
// (render/picker.ts) admits this kind alongside set-option/persist-option
|
|
154
|
+
// so a `{{ menu }}`/`{{ picker }}` can drive it with zero picker changes;
|
|
155
|
+
// only the WRITE (realize(), below) differs — it encodes the picked option
|
|
156
|
+
// into a LayoutOp instead of persisting it as-is.
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: "layout-op-option";
|
|
159
|
+
readonly key: string;
|
|
160
|
+
readonly anchor: string;
|
|
161
|
+
readonly relation: "before" | "after";
|
|
162
|
+
readonly options: readonly string[];
|
|
163
|
+
}
|
|
164
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history
|
|
165
|
+
// step over the overrides layer — `reset`'s fine-grained sibling. No key:
|
|
166
|
+
// there is nothing to carry, since the history stack (not this action) is
|
|
167
|
+
// what decides which entry moves.
|
|
168
|
+
| { readonly kind: "undo" }
|
|
169
|
+
| { readonly kind: "redo" };
|
|
146
170
|
|
|
147
171
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
148
172
|
|
|
@@ -320,6 +344,17 @@ function compileAction(
|
|
|
320
344
|
},
|
|
321
345
|
};
|
|
322
346
|
}
|
|
347
|
+
if ("insertSegmentFrom" in action) {
|
|
348
|
+
return {
|
|
349
|
+
kind: "layout-op-option",
|
|
350
|
+
key: action.persist,
|
|
351
|
+
anchor: action.anchor,
|
|
352
|
+
relation: action.relation,
|
|
353
|
+
options: [
|
|
354
|
+
...resolveOptionDomain(action.insertSegmentFrom, perConfigDomains),
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
323
358
|
return {
|
|
324
359
|
kind: "persist-bounded",
|
|
325
360
|
key: action.persist,
|
|
@@ -338,7 +373,10 @@ function compileAction(
|
|
|
338
373
|
target: parseActionTemplate(parse, action.open, name),
|
|
339
374
|
};
|
|
340
375
|
}
|
|
341
|
-
|
|
376
|
+
if ("reset" in action) {
|
|
377
|
+
return { kind: "reset", key: action.reset };
|
|
378
|
+
}
|
|
379
|
+
return "undo" in action ? { kind: "undo" } : { kind: "redo" };
|
|
342
380
|
}
|
|
343
381
|
|
|
344
382
|
function parseActionTemplate(
|
|
@@ -548,6 +586,20 @@ function realize(
|
|
|
548
586
|
effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
|
|
549
587
|
active: false,
|
|
550
588
|
};
|
|
589
|
+
// [LAW:one-source-of-truth] No key to carry — the click just says "step
|
|
590
|
+
// the history", and which entry moves is entirely server-side state
|
|
591
|
+
// (never wire input, so there is nothing here to gate). Never "active":
|
|
592
|
+
// a history step is a one-shot trigger, not a current-selection toggle.
|
|
593
|
+
case "undo":
|
|
594
|
+
return {
|
|
595
|
+
effect: { verb: VERB_UNDO, args: [sessionId] },
|
|
596
|
+
active: false,
|
|
597
|
+
};
|
|
598
|
+
case "redo":
|
|
599
|
+
return {
|
|
600
|
+
effect: { verb: VERB_REDO, args: [sessionId] },
|
|
601
|
+
active: false,
|
|
602
|
+
};
|
|
551
603
|
// [LAW:one-source-of-truth] The op is fixed at compile time (see
|
|
552
604
|
// compileAction) — the click just delivers it. `apply-layout-op`'s
|
|
553
605
|
// handler does read-current-append-write (see verbs/index.ts), unlike
|
|
@@ -562,6 +614,28 @@ function realize(
|
|
|
562
614
|
},
|
|
563
615
|
active: false,
|
|
564
616
|
};
|
|
617
|
+
// [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
|
|
618
|
+
// SAME resolution persist-option uses) becomes the op's `segment`; anchor/
|
|
619
|
+
// relation are the compiled literals. Same wire shape a literal layout-op
|
|
620
|
+
// emits, so the daemon's apply-layout-op handler and undo/redo need no
|
|
621
|
+
// knowledge of where the segment name came from. Never "active": a
|
|
622
|
+
// structural edit is a one-shot trigger, not a current-selection toggle.
|
|
623
|
+
case "layout-op-option": {
|
|
624
|
+
const segment = boundValue ?? display;
|
|
625
|
+
const op: LayoutOp = {
|
|
626
|
+
op: "insert",
|
|
627
|
+
segment,
|
|
628
|
+
anchor: c.anchor,
|
|
629
|
+
relation: c.relation,
|
|
630
|
+
};
|
|
631
|
+
return {
|
|
632
|
+
effect: {
|
|
633
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
634
|
+
args: [sessionId, c.key, encodeLayoutOp(op)],
|
|
635
|
+
},
|
|
636
|
+
active: false,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
565
639
|
}
|
|
566
640
|
}
|
|
567
641
|
|