@cruxy/cli 1.3.0 → 1.5.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.
@@ -1,36 +1,122 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { ONBOARDING_FILE_NAME } from "../constants.js";
4
4
  import { globalDir, resolveApiKey } from "../config/index.js";
5
+ import { ONBOARDING_VERSION, OnboardingStateSchema, } from "./types.js";
5
6
  /**
6
7
  * First-run detection + onboarding-state persistence (U.6). "First run" is
7
8
  * deliberately derived from observable facts (no key + no completion marker)
8
9
  * rather than a flag, and is **TTY-gated** so a non-interactive run never
9
10
  * branches into an interactive flow.
10
11
  */
11
- const ONBOARDING_VERSION = 1;
12
12
  /** `~/.cruxy/onboarding.json` */
13
13
  export function onboardingStatePath() {
14
14
  return join(globalDir(), ONBOARDING_FILE_NAME);
15
15
  }
16
- /** Read persisted onboarding state, or `null` if absent/unreadable. */
16
+ /**
17
+ * Read persisted onboarding state, or `null` if absent, unreadable, or not the
18
+ * shape this file is supposed to hold.
19
+ *
20
+ * VALIDATED, not cast. `as OnboardingState` accepted any object at all, so a
21
+ * truncated write or a hand-edit could hand the flow a `completedAt` that was a
22
+ * number — and the completion marker is the one fact that decides whether a
23
+ * user is walked through setup again or silently isn't.
24
+ *
25
+ * An invalid file reads as ABSENT rather than throwing, which is the same
26
+ * treatment corrupt JSON already got: the worst outcome of re-onboarding is one
27
+ * extra prompt, and the worst outcome of a hard error is a CLI that won't start
28
+ * because of a file that only exists to say "you've done this before".
29
+ */
17
30
  export function readOnboardingState(file = onboardingStatePath()) {
18
31
  if (!existsSync(file))
19
32
  return null;
33
+ let parsed;
20
34
  try {
21
- const parsed = JSON.parse(readFileSync(file, "utf8"));
22
- if (parsed && typeof parsed === "object")
23
- return parsed;
35
+ parsed = JSON.parse(readFileSync(file, "utf8"));
24
36
  }
25
37
  catch {
26
38
  // A corrupt marker is treated as absent — onboarding can rewrite it.
39
+ return null;
27
40
  }
28
- return null;
41
+ const result = OnboardingStateSchema.safeParse(parsed);
42
+ return result.success ? migrateState(result.data) : null;
43
+ }
44
+ /**
45
+ * The version branch the file has been writing a number for since U.6 and
46
+ * nothing has ever read.
47
+ *
48
+ * OLDER than this build: bring it forward. There is exactly one version today,
49
+ * so this is a re-stamp and nothing else — the point is that the next bump has
50
+ * one obvious place to add its step, instead of discovering at that moment that
51
+ * every reader assumed v1.
52
+ *
53
+ * NEWER than this build: return it untouched and keep trusting `completedAt`.
54
+ * Both alternatives are worse. Discarding it re-runs onboarding for someone who
55
+ * has plainly finished it, every time they use the older binary — running two
56
+ * cruxy versions against one home directory is ordinary, and being re-prompted
57
+ * for a key you already saved is not a reasonable punishment for it. Rewriting
58
+ * it down to our shape would destroy state we don't understand. `completedAt`
59
+ * is the marker in every version that has one; reading it across a version we
60
+ * don't know is a smaller assumption than either.
61
+ */
62
+ function migrateState(state) {
63
+ if (state.version >= ONBOARDING_VERSION)
64
+ return state;
65
+ return { ...state, version: ONBOARDING_VERSION };
29
66
  }
30
- /** Persist onboarding state (creates `~/.cruxy` if needed). */
67
+ /**
68
+ * Persist onboarding state (creates `~/.cruxy` if needed), owner-only.
69
+ *
70
+ * MERGED over what is on disk, not written over it. The schema passes unknown
71
+ * keys through, so a field a newer cruxy wrote survives a run of an older one
72
+ * — tolerating it on read and then flattening it on the next write would lose
73
+ * the data anyway, just one write later.
74
+ *
75
+ * The version is the MAX of the two for the same reason `migrateState` doesn't
76
+ * discard a future file: this build preserved the newer keys, so stamping our
77
+ * own lower number over them would tell the newer cruxy that its migration
78
+ * hasn't run when its data is in fact all still there.
79
+ */
31
80
  export function writeOnboardingState(state, file = onboardingStatePath()) {
81
+ const existing = readOnboardingState(file);
82
+ const merged = {
83
+ ...existing,
84
+ ...state,
85
+ version: Math.max(existing?.version ?? 0, state.version),
86
+ };
32
87
  mkdirSync(dirname(file), { recursive: true });
33
- writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
88
+ writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", {
89
+ encoding: "utf8",
90
+ mode: 0o600,
91
+ });
92
+ restrictToOwner(file);
93
+ }
94
+ /**
95
+ * Make the marker owner-only. It was the one store under `~/.cruxy` still at
96
+ * `0644` — usage, memory trust, MCP trust and hook trust are all `0600` — and
97
+ * while it holds no secret, it does record that this machine's user has a cruxy
98
+ * key configured and when they set it up. That is nobody else's business on a
99
+ * shared box, and "every file cruxy writes to your home directory is yours
100
+ * alone" is a rule worth being able to state without an exception.
101
+ *
102
+ * The `mode` on the write above only applies when the file is CREATED, so it
103
+ * does nothing for the `0644` files already out there; this fixes those on the
104
+ * next write. Best-effort and non-fatal, deliberately: unlike the credential
105
+ * store — which refuses to persist a secret it cannot secure — refusing to
106
+ * write this one would mean re-running onboarding forever on a filesystem
107
+ * without modes. Windows is skipped because POSIX modes are meaningless there
108
+ * (see `config/owner-only.ts`), and an `icacls` edit is more than a marker
109
+ * warrants.
110
+ */
111
+ function restrictToOwner(file) {
112
+ if (process.platform === "win32")
113
+ return;
114
+ try {
115
+ chmodSync(file, 0o600);
116
+ }
117
+ catch {
118
+ // A filesystem that can't do modes still gets a working marker.
119
+ }
34
120
  }
35
121
  /** A fresh state object. */
36
122
  export function newOnboardingState() {
@@ -1,6 +1,34 @@
1
+ import { z } from "zod";
1
2
  /**
2
3
  * Types for the first-run onboarding flow (U.6). The flow is built from injected
3
4
  * dependencies (validation, persistence, the first-win runner) so every step is
4
5
  * unit-testable with scripted IO and no real network or filesystem.
5
6
  */
6
- export {};
7
+ /**
8
+ * Bump when the on-disk onboarding state shape changes. The number is only
9
+ * worth writing if something READS it — see `migrateState` in `detect.ts`,
10
+ * which is where the branch it enables lives.
11
+ */
12
+ export const ONBOARDING_VERSION = 1;
13
+ /**
14
+ * The persisted state (`~/.cruxy/onboarding.json`), as a schema rather than a
15
+ * cast. It was read with `as OnboardingState` — a cast validates nothing, so
16
+ * `{"completedAt": 42}` or a JSON array parsed clean and the completion marker
17
+ * became whatever the file happened to contain.
18
+ *
19
+ * `.passthrough()`, for the reason `usage/types.ts` sets out at length: a read
20
+ * that can't validate is a read that discards, and a store hostile to its own
21
+ * future silently drops what a newer cruxy wrote the moment an older one runs
22
+ * against the same home directory. Passthrough both TOLERATES an unknown key
23
+ * and PRESERVES it, so `writeOnboardingState`'s merge hands it back intact.
24
+ */
25
+ export const OnboardingStateSchema = z
26
+ .object({
27
+ /** Shape version — see {@link ONBOARDING_VERSION}. */
28
+ version: z.number().int().nonnegative(),
29
+ /** ISO timestamp; **its presence is the completion marker**. */
30
+ completedAt: z.string().min(1).optional(),
31
+ /** Whether a key has been successfully configured at least once. */
32
+ keyConfigured: z.boolean().optional(),
33
+ })
34
+ .passthrough();
@@ -99,7 +99,13 @@ export function previewStats(preview) {
99
99
  }
100
100
  return [];
101
101
  }
102
- /** `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count. */
102
+ /**
103
+ * `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count.
104
+ *
105
+ * Takes only the three fields it reads rather than a whole {@link DiffFileStat},
106
+ * so P7's git-status changes render through this same formatter instead of a
107
+ * second one that would eventually disagree with it about what `null` means.
108
+ */
103
109
  export function formatStat(stat, c) {
104
110
  const parts = [];
105
111
  if (stat.added !== null && stat.added > 0)
@@ -1,9 +1,55 @@
1
+ import { capacityLevel, formatCapacity, } from "../utils/disk.js";
1
2
  import { fit } from "./layout.js";
2
3
  import { formatTokens } from "./state.js";
3
4
  /** `key value`, aligned on a fixed gutter so the column is scannable. */
4
5
  function row(key, value, t) {
5
6
  return ` ${t.muted(key.padEnd(11))} ${value}`;
6
7
  }
8
+ /**
9
+ * Collapse locations that report identical figures into one row, naming all of
10
+ * them.
11
+ *
12
+ * A project under `~` and `~/.cruxy` are usually the same filesystem, and two
13
+ * rows saying `54% free · 251 GiB of 465 GiB` twice is noise dressed as detail.
14
+ * Grouping is on the RENDERED numbers rather than on mount identity because
15
+ * `statfs` gives no filesystem id to compare — and grouping on the numbers is
16
+ * the honest test anyway: if two locations report the same total and the same
17
+ * free bytes, either they are one filesystem or they are two the reader has no
18
+ * way to tell apart, and in both cases one row is the whole of what's known.
19
+ */
20
+ function groupLocations(locations) {
21
+ const groups = new Map();
22
+ for (const loc of locations) {
23
+ const key = `${loc.capacity.totalBytes}:${loc.capacity.freeBytes}`;
24
+ const existing = groups.get(key);
25
+ if (existing)
26
+ existing.labels.push(loc.label);
27
+ else
28
+ groups.set(key, { labels: [loc.label], capacity: loc.capacity });
29
+ }
30
+ return [...groups.values()];
31
+ }
32
+ /**
33
+ * The disk rows: `disk cli, ~/.cruxy 54% free · 251 GiB of 465 GiB`.
34
+ *
35
+ * Coloured by {@link capacityLevel}, which judges absolute bytes — so a nearly
36
+ * full 4 TB disk with plenty left in it is not dressed up as an emergency, and
37
+ * a small VM disk with 800 MB left is, whatever percentage that happens to be.
38
+ * Only the first row carries the key; the rest align under it, because a `disk`
39
+ * label repeated down the column reads as several different facts.
40
+ */
41
+ function diskLines(locations, t) {
42
+ return groupLocations(locations).map((group, i) => {
43
+ const text = formatCapacity(group.capacity);
44
+ const level = capacityLevel(group.capacity);
45
+ const value = level === "critical"
46
+ ? t.danger(text)
47
+ : level === "low"
48
+ ? t.warning(text)
49
+ : t.muted(text);
50
+ return row(i === 0 ? "disk" : "", `${group.labels.join(", ")} ${value}`, t);
51
+ });
52
+ }
7
53
  /** The full `/status` block as lines to print. */
8
54
  export function sessionStatusLines(status, t, width = Infinity) {
9
55
  const lines = [t.heading("status")];
@@ -33,6 +79,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
33
79
  lines.push(row("checkpoints", status.checkpoints
34
80
  ? t.muted("on — `cruxy rollback` can undo a run's file changes")
35
81
  : t.warning("off — file changes are not restorable"), t));
82
+ // Next to checkpoints, the feature that turns a turn into a shadow copy of
83
+ // the tree: the two rows answer one question between them — will this
84
+ // session's writes land.
85
+ if (status.disk && status.disk.length > 0) {
86
+ lines.push(...diskLines(status.disk, t));
87
+ }
36
88
  if (status.jobs) {
37
89
  const { total, running, needingApproval } = status.jobs;
38
90
  const detail = total === 0
@@ -50,12 +102,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
50
102
  lines.push(t.strong(status.roots.length === 1 ? "root" : "roots"));
51
103
  for (const r of status.roots) {
52
104
  const mark = r.primary ? t.accent(t.glyph.pointer) : " ";
53
- const git = r.branch === undefined
54
- ? t.muted("not a git repo")
55
- : `${t.strong(r.branch)} ${r.changed === undefined
56
- ? ""
57
- : r.changed > 0
58
- ? t.warning(`${r.changed} changed`)
105
+ const git = r.git === undefined
106
+ ? t.muted(`checking${t.glyph.ellipsis}`)
107
+ : r.git === null
108
+ ? t.muted("not a git repo")
109
+ : `${t.strong(r.git.branch)} ${r.git.changed > 0
110
+ ? t.warning(`${r.git.changed} changed`)
59
111
  : t.success("clean")}`;
60
112
  lines.push(`${mark} ${r.name.padEnd(12)} ${git}`);
61
113
  lines.push(` ${t.muted(r.path)}`);
@@ -22,6 +22,8 @@ export const UNICODE_GLYPHS = {
22
22
  caretDown: "↓",
23
23
  cached: "↻",
24
24
  cursorBar: "▏",
25
+ barFilled: "█",
26
+ barEmpty: "░",
25
27
  bullet: "•",
26
28
  sep: "·",
27
29
  ellipsis: "…",
@@ -45,6 +47,8 @@ export const ASCII_GLYPHS = {
45
47
  caretDown: "v",
46
48
  cached: "",
47
49
  cursorBar: "|",
50
+ barFilled: "#",
51
+ barEmpty: ".",
48
52
  bullet: "*",
49
53
  sep: "-",
50
54
  ellipsis: "...",
@@ -72,6 +76,9 @@ export const SCREEN_READER_GLYPHS = {
72
76
  caretDown: "down",
73
77
  cached: "",
74
78
  cursorBar: "",
79
+ // A bar announces as nothing; the percentage beside it says everything.
80
+ barFilled: "",
81
+ barEmpty: "",
75
82
  bullet: "-",
76
83
  sep: "-",
77
84
  ellipsis: "...",
package/dist/tui/app.js CHANGED
@@ -2,6 +2,7 @@ import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js"
2
2
  import { completeLine } from "../components/autocomplete.js";
3
3
  import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
4
4
  import { selectList } from "../components/select.js";
5
+ import { viewLabel, viewOrder } from "./views.js";
5
6
  import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
6
7
  import { openPalette } from "./palette.js";
7
8
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
@@ -41,6 +42,7 @@ export const TUI_COMMANDS = [
41
42
  ...SHARED_COMMANDS,
42
43
  "/close",
43
44
  "/open",
45
+ "/view",
44
46
  ].sort();
45
47
  const HELP = [
46
48
  "commands:",
@@ -48,9 +50,12 @@ const HELP = [
48
50
  " /close <panel> hide a panel (sidebar | context | model | git | tools)",
49
51
  " or a whole column (rail = all four rail panels)",
50
52
  " /open <panel> show a hidden panel",
53
+ " /view [name] show a main-pane view, or list them",
51
54
  " Tab complete a slash command",
52
55
  " Ctrl+K open the command palette",
56
+ " Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
53
57
  " Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
58
+ " PgUp / PgDn scroll the pane; Esc returns to the live view",
54
59
  " Ctrl+D leave cruxy",
55
60
  ];
56
61
  /**
@@ -98,6 +103,43 @@ function panelArg(input, command) {
98
103
  const match = CLOSABLE_PANELS.find((p) => p === arg);
99
104
  return match ? { label: PANEL_LABELS[match], panels: [match] } : null;
100
105
  }
106
+ /**
107
+ * Keys the focused sidebar nav claims (P7 track 2). Returns whether it took
108
+ * the key — anything it declines falls through to the input line untouched.
109
+ *
110
+ * The set is deliberately small. Up/down move the selection and Enter is not
111
+ * needed to commit it: selecting a view IS the action, and a two-step
112
+ * move-then-confirm would make the arrows do nothing visible on their own.
113
+ * Esc and Ctrl+B both hand the keyboard back, so the way out is whichever of
114
+ * the two the user reaches for first.
115
+ *
116
+ * A PRINTABLE CHARACTER RETURNS FOCUS AND IS NOT SWALLOWED. Someone who starts
117
+ * typing has stopped navigating, and losing the first letter of a prompt to a
118
+ * focus mode they forgot they were in is the failure that makes people stop
119
+ * using the binding.
120
+ */
121
+ function handleNavKey(key, renderer) {
122
+ switch (key.kind) {
123
+ case "up":
124
+ renderer.cycleView(-1);
125
+ return true;
126
+ case "down":
127
+ renderer.cycleView(1);
128
+ return true;
129
+ case "escape":
130
+ case "ctrl-b":
131
+ case "enter":
132
+ renderer.focusSidebar(false);
133
+ return true;
134
+ case "char":
135
+ // Claim the focus change only; the character itself falls through to the
136
+ // editor on this same keypress.
137
+ renderer.focusSidebar(false);
138
+ return false;
139
+ default:
140
+ return false;
141
+ }
142
+ }
101
143
  /**
102
144
  * Read one line from the TUI's input row. Takes raw stdin for the duration and
103
145
  * always releases it. Resolves `null` on Ctrl+D / EOF.
@@ -110,11 +152,27 @@ async function readLine(keys, renderer, hooks) {
110
152
  try {
111
153
  for (;;) {
112
154
  const key = await keys.read();
155
+ // FOCUS IS A ROUTING DECISION, resolved before the editor sees anything
156
+ // (P7 track 2). While the sidebar holds the keyboard the arrows move the
157
+ // view selection instead of the cursor — but only the keys that mean
158
+ // something there are claimed, so scrolling, the palette and the mode ring
159
+ // keep working exactly as they do at the input line. A view is persistent
160
+ // and NON-EXCLUSIVE; this is not a modal, and nothing here blocks.
161
+ if (renderer.sidebarHasFocus() && handleNavKey(key, renderer)) {
162
+ paint();
163
+ continue;
164
+ }
113
165
  switch (key.kind) {
114
166
  case "enter": {
115
167
  const text = editor.text;
116
168
  editor.text = "";
117
169
  editor.cursor = 0;
170
+ // Submitting returns to the live view (P7 track 1). Scrollback holds
171
+ // its position against output arriving on its own, which is the point
172
+ // — but the user asking for that output is not "on its own", and
173
+ // answering into a pane they cannot see is the one case where holding
174
+ // still is wrong.
175
+ renderer.scrollToLive();
118
176
  paint();
119
177
  return text;
120
178
  }
@@ -205,9 +263,37 @@ async function readLine(keys, renderer, hooks) {
205
263
  hooks.cycleMode();
206
264
  paint();
207
265
  break;
266
+ case "page-up":
267
+ case "page-down":
268
+ // Scroll the conversation (P7 track 1). Like Shift+Tab, this leaves
269
+ // the line being typed completely alone: reading back over what was
270
+ // said is the most ordinary thing to do WHILE composing a reply, and
271
+ // a scroll that cost the draft would be unusable for exactly that.
272
+ //
273
+ // No `paint()`: the input row has not changed, and the renderer
274
+ // schedules its own repaint for the rows that did.
275
+ renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
276
+ break;
277
+ case "escape":
278
+ // Esc leaves the scrolled view — a mode needs a visible exit, and the
279
+ // notice names this key. It stays inert at the live tail rather than
280
+ // being claimed unconditionally, so the binding remains free for
281
+ // whatever a later track wants Esc to mean when nothing is scrolled.
282
+ renderer.scrollToLive();
283
+ break;
284
+ case "ctrl-b":
285
+ // Take the keyboard to the sidebar nav (P7 track 2). Refused when the
286
+ // sidebar is closed or dropped for width, and SAID rather than
287
+ // silently ignored: focus the user cannot see would make the arrows
288
+ // change meaning with nothing on screen to account for it.
289
+ if (!renderer.focusSidebar(true)) {
290
+ renderer.println(renderer.theme.muted("(no sidebar to focus — /open sidebar, or widen the terminal)"));
291
+ }
292
+ paint();
293
+ break;
208
294
  default:
209
- // escape / arrows up-down / ctrl-k: no binding yet, deliberately
210
- // inert rather than leaking a control char into the buffer.
295
+ // arrows: no binding yet, deliberately inert rather than leaking a
296
+ // control char into the buffer.
211
297
  break;
212
298
  }
213
299
  }
@@ -255,6 +341,10 @@ async function dispatch(input, session, renderer, out, slashCommands, checkpoint
255
341
  handlePanel(trimmed, "/open", true, renderer);
256
342
  return null;
257
343
  }
344
+ if (trimmed === "/view" || trimmed.startsWith("/view ")) {
345
+ handleView(trimmed, renderer);
346
+ return null;
347
+ }
258
348
  // Everything else is the SHARED implementation (P5 track 5). P1 forked this
259
349
  // loop out of `repl.ts` and left every one of these behind — including
260
350
  // `/plan`, which was then the only way to reach plan mode at all.
@@ -281,6 +371,39 @@ async function dispatch(input, session, renderer, out, slashCommands, checkpoint
281
371
  }
282
372
  return null;
283
373
  }
374
+ /**
375
+ * `/view [name]` — select a main-pane view, or list what there is (P7 track 2).
376
+ *
377
+ * Exists so switching views never depends on the sidebar. The nav is the
378
+ * discoverable path, but it can be closed or dropped for width, and a UI whose
379
+ * only route to a surface can vanish is one that strands people. This is also
380
+ * the scriptable path, which is what makes the register testable end to end.
381
+ */
382
+ function handleView(input, renderer) {
383
+ const t = renderer.theme;
384
+ const sources = renderer.viewSources();
385
+ const arg = input.slice("/view".length).trim().toLowerCase();
386
+ const ids = viewOrder(sources);
387
+ if (arg === "") {
388
+ renderer.println(t.muted("views:"));
389
+ for (const id of ids) {
390
+ const mark = id === renderer.view() ? t.glyph.pointer : " ";
391
+ renderer.println(t.muted(` ${mark} ${viewLabel(id, sources)}`));
392
+ }
393
+ return;
394
+ }
395
+ // Matched on the id AND the label, because the sidebar shows the label and
396
+ // that is what a user will type back. They are the same string for every
397
+ // view so far; keeping both accepted means they can diverge without the
398
+ // command quietly becoming wrong.
399
+ const target = ids.find((id) => id === arg || viewLabel(id, sources).toLowerCase() === arg);
400
+ if (target === undefined || !renderer.setView(target)) {
401
+ renderer.println(t.muted(`unknown view: ${arg}`));
402
+ renderer.println(t.muted(`try: ${ids.map((id) => viewLabel(id, sources)).join(" | ")}`));
403
+ return;
404
+ }
405
+ renderer.println(t.muted(`showing ${viewLabel(target, sources)}`));
406
+ }
284
407
  /** `/close <panel>` and `/open <panel>` share everything but the target state. */
285
408
  function handlePanel(input, command, open, renderer) {
286
409
  const t = renderer.theme;
@@ -0,0 +1,47 @@
1
+ import { readDiskCapacity } from "../utils/disk.js";
2
+ export class WorkspaceDiskCache {
3
+ paths;
4
+ probe;
5
+ values = new Map();
6
+ /** The refresh in flight, so concurrent triggers share one pass. */
7
+ inFlight = null;
8
+ constructor(paths, probe = readDiskCapacity) {
9
+ // Deduplicated: the same path twice would be the same syscall twice for the
10
+ // same answer. Two DIFFERENT paths on one filesystem are NOT collapsed here
11
+ // — that is a question about the numbers, and it is answered where they are
12
+ // rendered (see `status-view.ts`), not by guessing at mount identity.
13
+ this.paths = [...new Set(paths)];
14
+ this.probe = probe;
15
+ }
16
+ /**
17
+ * The last known capacity for one path — a map read, safe from the paint
18
+ * path. `undefined` means not probed yet, probed and unreadable, or a path
19
+ * this cache was never given; every one of those renders as nothing, so they
20
+ * do not need telling apart the way the git tri-state does.
21
+ */
22
+ current(path) {
23
+ return this.values.get(path);
24
+ }
25
+ /** Re-probe every path, concurrently, and resolve when all have settled. */
26
+ async refresh() {
27
+ if (this.inFlight)
28
+ return this.inFlight;
29
+ this.inFlight = this.run();
30
+ try {
31
+ await this.inFlight;
32
+ }
33
+ finally {
34
+ this.inFlight = null;
35
+ }
36
+ }
37
+ async run() {
38
+ await Promise.all(this.paths.map(async (path) => {
39
+ const value = await this.probe(path);
40
+ // A failed probe keeps the last good reading rather than blanking the
41
+ // row. A transient failure is not evidence that the disk changed, and a
42
+ // figure that flickers in and out is read as a bug in the tool.
43
+ if (value)
44
+ this.values.set(path, value);
45
+ }));
46
+ }
47
+ }
@@ -1,4 +1,4 @@
1
- import { getGitInfoAsync } from "../utils/git.js";
1
+ import { getGitInfoAsync, getGitTreeAsync, } from "../utils/git.js";
2
2
  export class GitStatusCache {
3
3
  cwd;
4
4
  probe;
@@ -61,3 +61,48 @@ export class GitStatusCache {
61
61
  }
62
62
  }
63
63
  }
64
+ /**
65
+ * The same discipline across every declared root (P7 track 3).
66
+ *
67
+ * The rail names ONE branch because it has one line to do it in; the Overview
68
+ * view reports every root, because writes fan across all of them when
69
+ * checkpoints are on and a primary-only answer would under-report what a turn
70
+ * touched. That is more subprocesses, not a different rule — so this is a fan
71
+ * over {@link GitStatusCache} rather than a second implementation of it, and
72
+ * `current` stays the pure field read the paint path is allowed to call.
73
+ *
74
+ * Probes run CONCURRENTLY. They are independent processes against independent
75
+ * working trees, and serialising them would make a four-root workspace four
76
+ * times slower to settle for no gain.
77
+ */
78
+ export class WorkspaceGitCache {
79
+ caches = new Map();
80
+ /**
81
+ * Probes with {@link getGitTreeAsync} by default, not the rail's cheaper
82
+ * {@link getGitInfoAsync} (P7 track 4): its consumers are the Overview and
83
+ * Git views, and the Git view needs the per-file list. The rail keeps the two
84
+ * -subprocess probe, because one line of branch-and-count has no use for a
85
+ * third.
86
+ */
87
+ constructor(paths, probe = getGitTreeAsync) {
88
+ for (const p of paths)
89
+ this.caches.set(p, new GitStatusCache(p, probe));
90
+ }
91
+ /**
92
+ * Last known state for one root — a field read, safe from the paint path.
93
+ * An unknown path reads as unprobed rather than throwing: the workspace can
94
+ * gain a root (`/add-root`) between this cache being built and a paint.
95
+ */
96
+ current(absPath) {
97
+ return this.caches.get(absPath)?.current();
98
+ }
99
+ /** Mark every root as possibly changed. Cheap and idempotent. */
100
+ invalidate() {
101
+ for (const c of this.caches.values())
102
+ c.invalidate();
103
+ }
104
+ /** Re-probe every stale root, in parallel, and resolve when all have settled. */
105
+ async refresh() {
106
+ await Promise.all([...this.caches.values()].map((c) => c.refresh()));
107
+ }
108
+ }