@promptctl/cc-candybar 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 +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { RichText } from "@promptctl/rich-js";
|
|
4
|
+
import { buildNeededPrefixes } from "../render-payload.js";
|
|
5
|
+
import {
|
|
6
|
+
loadConfig,
|
|
7
|
+
validateConfig,
|
|
8
|
+
resolveDslConfigPath,
|
|
9
|
+
dslConfigCandidatePaths,
|
|
10
|
+
detectConfigCollisions,
|
|
11
|
+
ConfigError,
|
|
12
|
+
} from "../../config/dsl-loader.js";
|
|
13
|
+
import type { ValidatedConfig } from "../../config/dsl-types.js";
|
|
14
|
+
import { registerDslConfig, type CompiledConfig } from "../../dsl/render.js";
|
|
15
|
+
import {
|
|
16
|
+
deriveActionValidators,
|
|
17
|
+
registerStateValidator,
|
|
18
|
+
} from "../verbs/state-validators.js";
|
|
19
|
+
import { VariableStore } from "../../var-system/store.js";
|
|
20
|
+
import { SourceRegistry } from "../../var-system/sources.js";
|
|
21
|
+
import type { GitDataProvider } from "./git.js";
|
|
22
|
+
import type { SessionStateRW } from "../session-state.js";
|
|
23
|
+
import type { WatcherRegistry, WatcherHandle } from "./watchers.js";
|
|
24
|
+
import { dlog } from "../log.js";
|
|
25
|
+
|
|
26
|
+
// [LAW:one-source-of-truth] Each cache entry owns the live DSL state for a
|
|
27
|
+
// (projectDir, cwd) tuple: the parsed config, the variable store +
|
|
28
|
+
// registry it was registered against, the compiled segment closures, and
|
|
29
|
+
// the resolved base palette. registerDslConfig + renderDsl are the
|
|
30
|
+
// single render path — the cache only holds state across calls.
|
|
31
|
+
//
|
|
32
|
+
// Capacity sized for "many concurrent sessions in many repos". Each entry
|
|
33
|
+
// holds a SourceRegistry (timers, watchers) so the hard cap doubles as a
|
|
34
|
+
// resource ceiling: at 256 active entries, fs watchers and TTL timers are
|
|
35
|
+
// bounded by N × declarations-per-config.
|
|
36
|
+
const MAX_ENTRIES = 256;
|
|
37
|
+
|
|
38
|
+
// [LAW:single-enforcer] These are the cache-and-registry deps — git data
|
|
39
|
+
// (for declareGit subscriptions), session state (for declareState atoms),
|
|
40
|
+
// and the watcher registry (for hot-reload's config file watcher). Daemon-
|
|
41
|
+
// owned data providers like the SessionUsageStore/git provider/etc. live in
|
|
42
|
+
// `payloadDeps` (server.ts) and feed `buildRenderPayload`; they are not
|
|
43
|
+
// part of cache identity or lifecycle.
|
|
44
|
+
export interface RenderDeps {
|
|
45
|
+
gitService: GitDataProvider;
|
|
46
|
+
sessionState: SessionStateRW;
|
|
47
|
+
watchers: WatcherRegistry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// [LAW:types-are-the-program] The DSL render state for an entry is one
|
|
51
|
+
// optionally-null bundle, not five independently-optional fields. Either
|
|
52
|
+
// every field is populated (a render is possible) or all are null (parse
|
|
53
|
+
// failed and we never had a valid config) — the type makes any other
|
|
54
|
+
// combination unrepresentable.
|
|
55
|
+
//
|
|
56
|
+
// `neededInputPaths` is the layout-reachable closure of input paths,
|
|
57
|
+
// computed once at registration. The daemon's payload builder reads it
|
|
58
|
+
// to gate provider invocation.
|
|
59
|
+
//
|
|
60
|
+
// `lastRenderCellsBySegment` is the per-segment StripCell sink that
|
|
61
|
+
// renderDsl writes on each render — pre-layout cells, NOT serialized
|
|
62
|
+
// ANSI. Storing cells (not strings) keeps the hot path free of the
|
|
63
|
+
// per-segment renderStripCells call: the debug projection serializes on
|
|
64
|
+
// demand only when a `debug segments` request actually arrives. The map
|
|
65
|
+
// identity is stable for the entry's lifetime; renderDsl clears +
|
|
66
|
+
// repopulates it in place. A segment hidden by `when` is absent from the
|
|
67
|
+
// map — its presence in the keys is the "this segment rendered" signal.
|
|
68
|
+
export interface DslRenderState {
|
|
69
|
+
readonly config: ValidatedConfig;
|
|
70
|
+
readonly store: VariableStore;
|
|
71
|
+
readonly registry: SourceRegistry;
|
|
72
|
+
readonly compiled: CompiledConfig;
|
|
73
|
+
readonly neededInputPaths: ReadonlySet<string>;
|
|
74
|
+
readonly lastRenderCellsBySegment: Map<string, readonly RichText[]>;
|
|
75
|
+
// [LAW:single-enforcer] Disposers for the SessionState validators this config
|
|
76
|
+
// installed (derived from its action table). Disposed on swap/eviction in the
|
|
77
|
+
// same dispose-before-swap transaction as the SourceRegistry, so a reload
|
|
78
|
+
// never leaks a stale writable-key entry or shadows the next config's keys.
|
|
79
|
+
readonly validatorDisposers: ReadonlyArray<() => void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// [LAW:one-source-of-truth] Each entry tracks the last *valid* DSL state +
|
|
83
|
+
// the last error AND last warning from a reload attempt. We never overwrite
|
|
84
|
+
// a valid state with nothing — a parse error means "show the warning but
|
|
85
|
+
// keep rendering with what we had". Errors are scoped to the cache key
|
|
86
|
+
// (which includes cwd / projectDir) so a broken config in repo A cannot
|
|
87
|
+
// pollute repo B.
|
|
88
|
+
//
|
|
89
|
+
// [LAW:one-type-per-behavior] error and warning are distinct severities, so
|
|
90
|
+
// they get distinct channels. `lastError` is load-fatal (config didn't
|
|
91
|
+
// parse / validate); `lastWarning` is advisory (e.g., extension collision —
|
|
92
|
+
// load succeeded but something the user should know about). The render path
|
|
93
|
+
// surfaces both through one diagnostics composer in src/daemon/server.ts.
|
|
94
|
+
// [LAW:types-are-the-program] `projectDir` and `cwd` are required inputs to
|
|
95
|
+
// every render request. The wire boundary in server.ts validates the
|
|
96
|
+
// underlying hookData and returns BAD_REQUEST when either is absent, so by
|
|
97
|
+
// the time a cache entry is built they are real non-empty paths. `configFile`
|
|
98
|
+
// is the (`~`-expanded) value of the client's `--config` flag — present
|
|
99
|
+
// when overriding the standard precedence chain, undefined otherwise. The
|
|
100
|
+
// type carries the optionality where it actually exists.
|
|
101
|
+
export interface CacheEntry {
|
|
102
|
+
projectDir: string;
|
|
103
|
+
cwd: string;
|
|
104
|
+
configFile: string | undefined;
|
|
105
|
+
configFilePath: string | null;
|
|
106
|
+
lastError: string | null;
|
|
107
|
+
lastWarning: string | null;
|
|
108
|
+
state: DslRenderState | null;
|
|
109
|
+
watcher: WatcherHandle | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// [LAW:one-source-of-truth] Cache key includes every input that affects DSL
|
|
113
|
+
// resolution: projectDir, cwd, and the resolved `--config` file (if provided).
|
|
114
|
+
// `projectDir`/`cwd` are real strings by construction (validated upstream);
|
|
115
|
+
// `configFile` collapses absent → empty in the key, distinct from any real path.
|
|
116
|
+
function cacheKey(
|
|
117
|
+
projectDir: string,
|
|
118
|
+
cwd: string,
|
|
119
|
+
configFile: string | undefined,
|
|
120
|
+
): string {
|
|
121
|
+
return projectDir + "\0" + cwd + "\0" + (configFile ?? "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class RenderCache {
|
|
125
|
+
private readonly entries = new Map<string, CacheEntry>();
|
|
126
|
+
private readonly deps: RenderDeps;
|
|
127
|
+
private readonly maxEntries: number;
|
|
128
|
+
|
|
129
|
+
constructor(deps: RenderDeps, opts: { maxEntries?: number } = {}) {
|
|
130
|
+
this.deps = deps;
|
|
131
|
+
this.maxEntries = opts.maxEntries ?? MAX_ENTRIES;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// [LAW:dataflow-not-control-flow] One uniform shape: every entry has the
|
|
135
|
+
// same fields, populated to nulls when reload failed. The renderer reads
|
|
136
|
+
// the data; no special-case branches between "first load", "reload",
|
|
137
|
+
// "reload-after-error".
|
|
138
|
+
getOrCreate(
|
|
139
|
+
projectDir: string,
|
|
140
|
+
cwd: string,
|
|
141
|
+
configFile: string | undefined,
|
|
142
|
+
): CacheEntry {
|
|
143
|
+
const key = cacheKey(projectDir, cwd, configFile);
|
|
144
|
+
const existing = this.entries.get(key);
|
|
145
|
+
if (existing) {
|
|
146
|
+
// Move to end (most recently used) for LRU eviction.
|
|
147
|
+
this.entries.delete(key);
|
|
148
|
+
this.entries.set(key, existing);
|
|
149
|
+
return existing;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entry: CacheEntry = {
|
|
153
|
+
projectDir,
|
|
154
|
+
cwd,
|
|
155
|
+
configFile,
|
|
156
|
+
configFilePath: null,
|
|
157
|
+
lastError: null,
|
|
158
|
+
lastWarning: null,
|
|
159
|
+
state: null,
|
|
160
|
+
watcher: null,
|
|
161
|
+
};
|
|
162
|
+
this.reloadInto(entry);
|
|
163
|
+
this.entries.set(key, entry);
|
|
164
|
+
|
|
165
|
+
if (this.entries.size > this.maxEntries) {
|
|
166
|
+
const oldest = this.entries.keys().next().value;
|
|
167
|
+
if (oldest !== undefined) {
|
|
168
|
+
const evicted = this.entries.get(oldest);
|
|
169
|
+
// [LAW:single-enforcer] dispose the registry on eviction — it owns
|
|
170
|
+
// timers, fs watchers, and git subscriptions. Dropping the entry
|
|
171
|
+
// without dispose leaks every async handle the config declared. The
|
|
172
|
+
// validator disposers free this entry's writable-key entries too.
|
|
173
|
+
evicted?.state?.registry.dispose();
|
|
174
|
+
evicted?.state?.validatorDisposers.forEach((dispose) => dispose());
|
|
175
|
+
evicted?.watcher?.release();
|
|
176
|
+
this.entries.delete(oldest);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Populate (or re-populate) `entry` from the current state of disk.
|
|
184
|
+
//
|
|
185
|
+
// - Parse success: dispose the prior state (if any), build fresh store +
|
|
186
|
+
// registry + compiled, clear lastError.
|
|
187
|
+
// - Parse failure: keep the prior state, set lastError. First-time
|
|
188
|
+
// failures leave state null (startup-error case).
|
|
189
|
+
//
|
|
190
|
+
// [LAW:single-enforcer] hot-reload contract: any reload that produces a
|
|
191
|
+
// new DslConfig disposes the old SourceRegistry before constructing a new
|
|
192
|
+
// one. The registry owns timers, watchers, MobX reactions, and git
|
|
193
|
+
// subscriptions — dropping it without dispose leaks every handle.
|
|
194
|
+
private reloadInto(entry: CacheEntry): void {
|
|
195
|
+
const resolvedPath = resolveDslConfigPath(
|
|
196
|
+
entry.projectDir,
|
|
197
|
+
entry.cwd,
|
|
198
|
+
entry.configFile,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// [LAW:dataflow-not-control-flow] Collision detection runs every reload,
|
|
202
|
+
// independent of load success — even if the .json5 fails to parse, the
|
|
203
|
+
// user still wants to know they have a shadowed .json sibling. Pure
|
|
204
|
+
// file-existence checks, so cheap. The watcher already monitors every
|
|
205
|
+
// candidate path, so creating/removing a duplicate triggers reload and
|
|
206
|
+
// re-detection automatically; nothing else needs to invalidate this.
|
|
207
|
+
entry.lastWarning = detectConfigCollisions(entry.projectDir, entry.cwd);
|
|
208
|
+
|
|
209
|
+
// [LAW:dataflow-not-control-flow] One uniform shape: build the new
|
|
210
|
+
// state into locals first, dispose the old registry ONLY after every
|
|
211
|
+
// construction step has succeeded. A failure at any step — parse,
|
|
212
|
+
// registration, palette resolution — leaves `entry.state` and
|
|
213
|
+
// `entry.state.registry` untouched, so the daemon keeps rendering the
|
|
214
|
+
// last-known-good config plus a warning icon (composeWithDiagnostics
|
|
215
|
+
// reads `entry.lastError` and `entry.lastWarning`). The
|
|
216
|
+
// "[LAW:single-enforcer] dispose before swap" contract holds for the
|
|
217
|
+
// swap; the construction is upstream of it.
|
|
218
|
+
let newState: DslRenderState;
|
|
219
|
+
try {
|
|
220
|
+
newState = this.buildState(entry, resolvedPath);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
entry.lastError =
|
|
223
|
+
err instanceof ConfigError
|
|
224
|
+
? err.message
|
|
225
|
+
: err instanceof Error
|
|
226
|
+
? err.message
|
|
227
|
+
: String(err);
|
|
228
|
+
// Watch the broken file (and its sibling candidates) so an in-place
|
|
229
|
+
// save OR a higher-precedence file appearing recovers.
|
|
230
|
+
this.refreshWatcher(entry, resolvedPath);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// [LAW:single-enforcer] Dispose-before-swap: the old registry owns timers,
|
|
235
|
+
// fs watchers, MobX reactions, and git subscriptions; the old validator
|
|
236
|
+
// disposers own this entry's writable-key entries in the global registry.
|
|
237
|
+
// Both are disposed in one step before the swap — dropping either reference
|
|
238
|
+
// without disposing would leak handles or shadow the new config's keys.
|
|
239
|
+
entry.state?.registry.dispose();
|
|
240
|
+
entry.state?.validatorDisposers.forEach((dispose) => dispose());
|
|
241
|
+
entry.lastError = null;
|
|
242
|
+
entry.state = newState;
|
|
243
|
+
// [LAW:dataflow-not-control-flow] Partial-load warnings (variable
|
|
244
|
+
// declaration failures that didn't abort the load) flow through the same
|
|
245
|
+
// warning channel as collision warnings, already set above. Append rather
|
|
246
|
+
// than replace so both can be visible at once.
|
|
247
|
+
if (newState.compiled.loadWarnings.length > 0) {
|
|
248
|
+
const vw = newState.compiled.loadWarnings.join("\n");
|
|
249
|
+
entry.lastWarning = entry.lastWarning
|
|
250
|
+
? entry.lastWarning + "\n" + vw
|
|
251
|
+
: vw;
|
|
252
|
+
}
|
|
253
|
+
this.refreshWatcher(entry, resolvedPath);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// [LAW:single-enforcer] Construct the full new state — parsed config,
|
|
257
|
+
// store, registry, compiled segments, palette — as one transaction. Any
|
|
258
|
+
// failure inside disposes the partially-built registry so we don't leak
|
|
259
|
+
// timers/watchers from a half-constructed reload, then rethrows so the
|
|
260
|
+
// caller (reloadInto) preserves the prior `entry.state` unchanged.
|
|
261
|
+
private buildState(
|
|
262
|
+
entry: CacheEntry,
|
|
263
|
+
resolvedPath: string | null,
|
|
264
|
+
): DslRenderState {
|
|
265
|
+
// [LAW:dataflow-not-control-flow][LAW:single-enforcer] Three primitives,
|
|
266
|
+
// straight-line composition. `loadConfig(null)` returns the bundled
|
|
267
|
+
// default (uniform merge against empty raw); `validateConfig` is the
|
|
268
|
+
// sole producer of `ValidatedConfig`. The renderer accepts only
|
|
269
|
+
// `ValidatedConfig`, so the compiler enforces the chain — there is no
|
|
270
|
+
// "skip validate" path that typechecks downstream.
|
|
271
|
+
// [LAW:one-source-of-truth] Thread the source through to validateConfig so
|
|
272
|
+
// cross-ref diagnostics on the daemon path carry real line numbers and the
|
|
273
|
+
// authored-surface (root vs layout) discriminator works — the file is read
|
|
274
|
+
// once inside loadConfig, not re-read here.
|
|
275
|
+
const { config: merged, source } = loadConfig(resolvedPath);
|
|
276
|
+
const config = validateConfig(merged, resolvedPath ?? "<default>", source);
|
|
277
|
+
|
|
278
|
+
const store = new VariableStore();
|
|
279
|
+
// [LAW:single-enforcer] Inject the daemon's shared GitDataProvider so
|
|
280
|
+
// every config's `kind: "git"` declarations route through one cache +
|
|
281
|
+
// watcher pool (rather than each registry standing up its own). The
|
|
282
|
+
// sessionState injection makes `kind: "state"` variables read/write the
|
|
283
|
+
// same per-session store the click verbs mutate. `default_empty_value`
|
|
284
|
+
// is honored from globals — it's the fallback used by input/env/etc.
|
|
285
|
+
// sources when neither the path resolves nor the declaration carries
|
|
286
|
+
// its own `default`. The loader validates it as a string; the registry
|
|
287
|
+
// default ("") matches the historical behavior when omitted.
|
|
288
|
+
const registry = new SourceRegistry(
|
|
289
|
+
store,
|
|
290
|
+
config.globals.default_empty_value ?? "",
|
|
291
|
+
this.deps.gitService,
|
|
292
|
+
this.deps.sessionState,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
let compiled: CompiledConfig;
|
|
296
|
+
// [LAW:single-enforcer] Validators this config installs (one per menu page
|
|
297
|
+
// key) are part of the same construction transaction as the registry: any
|
|
298
|
+
// failure (registration, a duplicate-key throw) disposes every handle built
|
|
299
|
+
// so far — registry AND already-installed validators — before rethrowing, so
|
|
300
|
+
// reloadInto preserves the prior last-known-good with nothing half-installed.
|
|
301
|
+
const validatorDisposers: Array<() => void> = [];
|
|
302
|
+
try {
|
|
303
|
+
// [LAW:locality-or-seam] Pass the store so the config's `widget`
|
|
304
|
+
// references can read session.id + current picker values from the same
|
|
305
|
+
// source the rest of the render reads.
|
|
306
|
+
compiled = registerDslConfig(config, registry, {
|
|
307
|
+
cwd: entry.cwd,
|
|
308
|
+
store,
|
|
309
|
+
});
|
|
310
|
+
// [LAW:one-source-of-truth] Derive the writable-key validators from the
|
|
311
|
+
// config's action table (the sole interaction authority) through one
|
|
312
|
+
// coherence merge (deriveActionValidators), then register them so the click
|
|
313
|
+
// wire accepts the picker's ←/→/apply-close writes and every other action
|
|
314
|
+
// write alike. Merging before registration lets a trigger's literal "0" be
|
|
315
|
+
// absorbed into a picker's int page gate instead of colliding.
|
|
316
|
+
// registerStateValidator throws on a duplicate baseline key — caught here to
|
|
317
|
+
// roll the whole reload back.
|
|
318
|
+
for (const { key, spec } of deriveActionValidators(config)) {
|
|
319
|
+
validatorDisposers.push(registerStateValidator(key, spec));
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
for (const dispose of validatorDisposers) dispose();
|
|
323
|
+
registry.dispose();
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// [LAW:one-source-of-truth] basePalette is NOT frozen here. One cache entry
|
|
328
|
+
// serves many sessions, but the effective theme is per-session SessionState;
|
|
329
|
+
// freezing the palette per entry would let the rendered colors diverge from
|
|
330
|
+
// the session's chosen theme. The server resolves basePalette per render
|
|
331
|
+
// from the effective theme (resolverForThemeName ∘ effectiveThemeName).
|
|
332
|
+
return {
|
|
333
|
+
config,
|
|
334
|
+
store,
|
|
335
|
+
registry,
|
|
336
|
+
compiled,
|
|
337
|
+
neededInputPaths: buildNeededPrefixes(config),
|
|
338
|
+
lastRenderCellsBySegment: new Map<string, readonly RichText[]>(),
|
|
339
|
+
validatorDisposers,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// [LAW:single-enforcer] One watcher-rebind decision per reload. If the
|
|
344
|
+
// resolved path changed (including null↔non-null transitions), or no
|
|
345
|
+
// watcher is currently held, install a fresh watcher set keyed by the
|
|
346
|
+
// current resolved path (or `<none>` when nothing exists). The "watch all
|
|
347
|
+
// candidates when no file exists" behavior lives in rebindWatcher.
|
|
348
|
+
private refreshWatcher(entry: CacheEntry, resolvedPath: string | null): void {
|
|
349
|
+
if (resolvedPath !== entry.configFilePath || entry.watcher === null) {
|
|
350
|
+
entry.configFilePath = resolvedPath;
|
|
351
|
+
this.rebindWatcher(entry, resolvedPath);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private rebindWatcher(entry: CacheEntry, targetPath: string | null): void {
|
|
356
|
+
if (entry.watcher) {
|
|
357
|
+
entry.watcher.release();
|
|
358
|
+
entry.watcher = null;
|
|
359
|
+
}
|
|
360
|
+
// [LAW:dataflow-not-control-flow] Two outcomes from one rule:
|
|
361
|
+
// resolved path exists → watch THAT file + its parent dir (catches
|
|
362
|
+
// in-place writes and atomic-rename writes that replace the inode)
|
|
363
|
+
// no resolved path → watch EVERY candidate's parent dir so the
|
|
364
|
+
// creation of any file in the resolution chain triggers reload.
|
|
365
|
+
// Either way the watcher set is built from a single list of (dir,
|
|
366
|
+
// filename-filter) tuples; the only variability is whether the
|
|
367
|
+
// currently-resolved file is also added to `files` for inode-level
|
|
368
|
+
// watching.
|
|
369
|
+
// [LAW:dataflow-not-control-flow] fs.watch on a non-existent directory
|
|
370
|
+
// throws; on a fresh install $XDG_CONFIG_HOME/cc-candybar doesn't exist
|
|
371
|
+
// yet. Filter candidates to those whose parent directory exists *at
|
|
372
|
+
// this moment* — that's the bounded set of locations we can usefully
|
|
373
|
+
// watch. (A user creating the XDG dir later would only get hot-reload
|
|
374
|
+
// for the project-local / cwd locations until the daemon next builds
|
|
375
|
+
// an entry; this is a documented limitation, not a contract violation.)
|
|
376
|
+
// [LAW:single-enforcer] Same enumerator the resolver uses, so the watcher
|
|
377
|
+
// covers the exact same set of paths the next reload would consult — a
|
|
378
|
+
// `--config` override collapses to one candidate; absent, the precedence
|
|
379
|
+
// chain unfolds in full.
|
|
380
|
+
const candidates = dslConfigCandidatePaths(
|
|
381
|
+
entry.projectDir,
|
|
382
|
+
entry.cwd,
|
|
383
|
+
entry.configFile,
|
|
384
|
+
);
|
|
385
|
+
const dirSet = new Map<string, Set<string>>();
|
|
386
|
+
for (const candidate of candidates) {
|
|
387
|
+
const dir = path.dirname(candidate);
|
|
388
|
+
if (!fs.existsSync(dir)) continue;
|
|
389
|
+
const base = path.basename(candidate);
|
|
390
|
+
if (!dirSet.has(dir)) dirSet.set(dir, new Set());
|
|
391
|
+
dirSet.get(dir)!.add(base);
|
|
392
|
+
}
|
|
393
|
+
const dirs = [...dirSet.entries()].map(([dirPath, names]) => ({
|
|
394
|
+
path: dirPath,
|
|
395
|
+
filenames: [...names],
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
// [LAW:single-enforcer] Watcher keys are per-cache-entry, not per-file.
|
|
399
|
+
// WatcherRegistry.acquire() is share-by-key — multiple entries that
|
|
400
|
+
// resolve to the same config file would otherwise share one watcher
|
|
401
|
+
// slot whose `onInvalidate` is overwritten by the last acquire, and
|
|
402
|
+
// earlier entries would never reload on file changes. Including
|
|
403
|
+
// (projectDir, cwd, configFile) in every key guarantees each entry owns
|
|
404
|
+
// its own watcher slot bound to its own reload callback.
|
|
405
|
+
const key = `config:${entry.projectDir}:${entry.cwd}:${entry.configFile ?? ""}:${targetPath ?? "<none>"}`;
|
|
406
|
+
|
|
407
|
+
entry.watcher = this.deps.watchers.acquire(
|
|
408
|
+
key,
|
|
409
|
+
{
|
|
410
|
+
files: targetPath !== null ? [targetPath] : [],
|
|
411
|
+
dirs,
|
|
412
|
+
},
|
|
413
|
+
() => this.onConfigChanged(entry),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// [LAW:single-enforcer] One reload dispatcher per cache entry. The
|
|
418
|
+
// watcher fires on any change in any candidate dir matching the
|
|
419
|
+
// CONFIG_FILENAME; the entry re-resolves its own resolution chain (so a
|
|
420
|
+
// higher-precedence file appearing supersedes a lower one) and reloads.
|
|
421
|
+
// We don't filter by which specific path changed because the
|
|
422
|
+
// (projectDir, cwd) tuple already scopes the entry's watcher set —
|
|
423
|
+
// sibling entries with different scopes don't share this watcher.
|
|
424
|
+
private onConfigChanged(entry: CacheEntry): void {
|
|
425
|
+
dlog(
|
|
426
|
+
"info",
|
|
427
|
+
`config change detected for entry projectDir=${entry.projectDir} cwd=${entry.cwd}`,
|
|
428
|
+
);
|
|
429
|
+
this.reloadInto(entry);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
get size(): number {
|
|
433
|
+
return this.entries.size;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// [LAW:single-enforcer] One read path for "any populated state" used by
|
|
437
|
+
// the debug protocol's introspection (`debug vars` / `segments` / `config`).
|
|
438
|
+
// Iterates existing entries — does NOT call getOrCreate, so debug
|
|
439
|
+
// introspection never has the side effect of creating a fresh cache entry
|
|
440
|
+
// (with its own SourceRegistry timers/watchers) tied to the daemon's own
|
|
441
|
+
// `process.cwd()`. Returns null when the cache has no successfully-loaded
|
|
442
|
+
// entry; debug responses are empty in that case by construction.
|
|
443
|
+
firstPopulatedState(): DslRenderState | null {
|
|
444
|
+
for (const entry of this.entries.values()) {
|
|
445
|
+
if (entry.state !== null) return entry.state;
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|