pi-weave 0.1.7 → 0.1.9

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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The graph column (weave-workspace §1.2, §7, P3).
3
+ *
4
+ * Props in, JSX out, and one `ref` handed to a renderer. Every string, every
5
+ * branch and every set comes from `column.model.ts`, `graph.model.ts` or
6
+ * `positions.ts`; what is left here is a `useState`, three effects and four
7
+ * handlers. §10's rule, and the reason the 95 % gate survives a phase whose
8
+ * whole deliverable is a WebGL canvas.
9
+ *
10
+ * The three effects, all one-liners over injected units:
11
+ *
12
+ * 1. **mount** — build a renderer, attach it to the `<div>`, subscribe to
13
+ * selection. The returned `destroy` is the cleanup, so a hot reload cannot
14
+ * leak a WebGL context.
15
+ * 2. **graph** — push the drawn model whenever the reduction or the layout
16
+ * changes. Keyed on `model.key`, which is the *shape* digest, so an SSE
17
+ * tick that changed a note's body does not re-upload the graph (§7.3:
18
+ * re-run on drag or expand/collapse, not every frame).
19
+ * 3. **highlight** — push the neighbourhood whenever the selection moves.
20
+ * This is the §1.3 bus arriving from the tree, a wikilink or the context
21
+ * rail; the graph does not care which, and that is the whole mechanism
22
+ * behind "selecting anywhere highlights everywhere".
23
+ *
24
+ * ## The `live` ref
25
+ *
26
+ * The renderer's `onSelect` is registered once at mount and outlives every
27
+ * render, so a handler that closed over `view` and `model` directly would keep
28
+ * answering with the state as it was at mount. The same `live.current` pattern
29
+ * `Shell.tsx` uses for its drag handlers, for the same reason.
30
+ */
31
+
32
+ import { useEffect, useMemo, useRef, useState, useCallback } from "preact/hooks";
33
+ import type { GraphPayload } from "../../shared/wire";
34
+ import type { GraphViewState } from "./column.model";
35
+ import {
36
+ FIT_HINT,
37
+ FIT_LABEL,
38
+ LEGEND,
39
+ allExpanded,
40
+ effectiveView,
41
+ expandHint,
42
+ expandLabel,
43
+ graphClick,
44
+ graphColumnModel,
45
+ graphCountLabel,
46
+ toggleExpandAll,
47
+ } from "./column.model";
48
+ import type { PositionStorage } from "./positions";
49
+ import type { GraphRenderer, RendererFactory } from "./renderer";
50
+ import { schemeOf } from "./scheme";
51
+ import type { SchemeHost } from "./scheme";
52
+ import { createGraphSimulation } from "./dynamics";
53
+ import type { GraphSimulation } from "./dynamics";
54
+
55
+ export interface GraphProps {
56
+ graph: GraphPayload | null;
57
+ selectedId: string | null;
58
+ onSelect: (id: string | null) => void;
59
+ /** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
60
+ renderer: RendererFactory;
61
+ /** Injected: `localStorage`. See `positions.ts` for why it is a port. */
62
+ storage: PositionStorage;
63
+ /** Injected: `window`. Read once, for `prefers-color-scheme`. */
64
+ host: SchemeHost;
65
+ /**
66
+ * A slot the column writes its `fit` into, so the global `g` key can reach
67
+ * it (§11 P4).
68
+ *
69
+ * A ref rather than lifting the renderer into the shell: the renderer is
70
+ * created, owned and destroyed by the mount effect below, and hoisting it
71
+ * would move a WebGL lifecycle out of the one component that can see the
72
+ * `<div>` it attaches to. A slot the column fills and clears on unmount
73
+ * keeps the ownership where it is and costs one line at each end.
74
+ */
75
+ fit: { current: (() => void) | null };
76
+ }
77
+
78
+ export function Graph(props: GraphProps) {
79
+ const canvas = useRef<HTMLDivElement | null>(null);
80
+ const renderer = useRef<GraphRenderer | null>(null);
81
+ const dynamics = useRef<GraphSimulation | null>(null);
82
+ const clock = useRef<number | null>(null);
83
+ /**
84
+ * Arm the simulation clock — idempotent.
85
+ *
86
+ * The layout is always live; the clock still sleeps whenever the engine's
87
+ * alpha reaches its floor (a graph that is holding still costs zero frames)
88
+ * and re-arms when a drag pins a node or a re-layout hands over a new
89
+ * engine.
90
+ */
91
+ const armClock = useCallback(() => {
92
+ if (clock.current !== null) return;
93
+ const step = () => {
94
+ clock.current = null;
95
+ const engine = dynamics.current;
96
+ if (engine === null) return;
97
+ engine.tick();
98
+ // Asleep: let the clock die. The next pin re-arms it.
99
+ if (!engine.awake()) return;
100
+ renderer.current?.setPositions(engine.positions());
101
+ clock.current = requestAnimationFrame(step);
102
+ };
103
+ clock.current = requestAnimationFrame(step);
104
+ }, []);
105
+ // `null` means "the user has not touched the expansion" — not "nothing is
106
+ // expanded". `effectiveView` resolves the difference; see its doc comment.
107
+ const [state, setState] = useState<GraphViewState | null>(null);
108
+
109
+ const scheme = useMemo(() => schemeOf(props.host), [props.host]);
110
+ const view = effectiveView(props.graph, state);
111
+ const model = graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme);
112
+ const everything = allExpanded(view, model.clusters);
113
+
114
+ // Read by the mount-time `onSelect`, which outlives this render.
115
+ const live = useRef({ view, model, onSelect: props.onSelect });
116
+ live.current = { view, model, onSelect: props.onSelect };
117
+
118
+ useEffect(() => {
119
+ const instance = props.renderer(scheme);
120
+ instance.onSelect((id) => {
121
+ const next = graphClick(live.current.view, live.current.model.clusters, id);
122
+ setState(next.state);
123
+ live.current.onSelect(next.selectedId);
124
+ });
125
+ instance.onDragStart((id) => {
126
+ const at = renderer.current?.positions().get(id);
127
+ if (at) {
128
+ dynamics.current?.pin(id, at);
129
+ // The pin re-heats the engine; if the clock was asleep, wake it.
130
+ armClock();
131
+ }
132
+ });
133
+ instance.onDragMove((id, at) => {
134
+ dynamics.current?.pin(id, at);
135
+ });
136
+ instance.onDragEnd((id) => {
137
+ dynamics.current?.release(id);
138
+ });
139
+ instance.mount(canvas.current ?? { clientWidth: 0, clientHeight: 0 });
140
+ renderer.current = instance;
141
+ props.fit.current = () => instance.fit();
142
+ return () => {
143
+ instance.destroy();
144
+ renderer.current = null;
145
+ // Cleared, not left dangling: `g` after a breakpoint has unmounted the
146
+ // graph column would otherwise call `fit` on a destroyed WebGL context.
147
+ props.fit.current = null;
148
+ };
149
+ }, [props.renderer, scheme]);
150
+
151
+ useEffect(() => {
152
+ renderer.current?.setGraph(model.graph);
153
+ }, [model.key]);
154
+
155
+ // Live layout, in two effects so pause/resume and re-layout are independent.
156
+ //
157
+ // Effect 1 (keyed on `model.key`) owns the *simulation*: it is created once
158
+ // per graph shape, warm-started from the renderer's current positions so a
159
+ // drag or an expand does not make the graph jump, and disposed when the shape
160
+ // changes. A fresh engine starts at settle alpha — the "alive" answer to an
161
+ // expand — so the clock must arm here if it had gone to sleep.
162
+ useEffect(() => {
163
+ const engine = createGraphSimulation(model.graph, renderer.current?.positions());
164
+ dynamics.current = engine;
165
+ if (engine !== null) armClock();
166
+ return () => {
167
+ dynamics.current = null;
168
+ };
169
+ }, [model.key, armClock]);
170
+
171
+ // Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is
172
+ // effect 1's, and the step self-terminates whenever the engine settles, so
173
+ // the only teardown left here is a frame that could outlive the component.
174
+ useEffect(
175
+ () => () => {
176
+ if (clock.current !== null) {
177
+ cancelAnimationFrame(clock.current);
178
+ clock.current = null;
179
+ }
180
+ },
181
+ [],
182
+ );
183
+
184
+ useEffect(() => {
185
+ renderer.current?.setHighlight(model.highlight);
186
+ }, [model.highlight]);
187
+
188
+ return (
189
+ <div class="weave-graph">
190
+ {/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
191
+ and what is left here is whether to render the paragraph it returned. */}
192
+ {model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
193
+ <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" />
194
+ <div class="weave-graph-controls">
195
+ <button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
196
+ {FIT_LABEL}
197
+ </button>
198
+ <button type="button" class="weave-chip" title={expandHint(everything)} onClick={() => setState(toggleExpandAll(view, model.clusters))}>
199
+ {expandLabel(everything)}
200
+ </button>
201
+ <span class="weave-graph-legend">
202
+ <span class="weave-legend-on">◉ {LEGEND.selected}</span>
203
+ <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
204
+ </span>
205
+ </div>
206
+ <p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
207
+ </div>
208
+ );
209
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The graph column's own state: what is expanded, what is highlighted, and
3
+ * what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3).
4
+ *
5
+ * `graph.model.ts` decides how a *node* is drawn. This module decides what the
6
+ * *column* is currently showing — which is a different question with a
7
+ * different lifetime, and keeping them apart is what stops either becoming a
8
+ * grab-bag:
9
+ *
10
+ * ```text
11
+ * payload + selection + view state ← this module
12
+ * │
13
+ * ▼ clusterAggregate (core) which nodes exist right now
14
+ * ▼ positions.ts where they are
15
+ * ▼ graph.model.ts how each one looks
16
+ * ▼ project.ts → renderer.ts drawn
17
+ * ```
18
+ *
19
+ * Every branch lives here for §10's reason: sigma needs a canvas, so a
20
+ * conditional inside the renderer is a conditional no test can reach.
21
+ * `Graph.tsx` is left with a `useState`, three effects and four handlers.
22
+ *
23
+ * ## The §1.3 context bus, from the graph's side
24
+ *
25
+ * There is one signal, `selectedId`, and the graph both writes and reads it.
26
+ * Clicking a node writes it — which drives the note column and the context
27
+ * rail with no further wiring. Selecting *anywhere* (tree row, wikilink,
28
+ * context rail) is read back here as {@link highlightFor}, so "selecting in
29
+ * the tree highlights in the graph" needs no code beyond deriving the
30
+ * neighbourhood from the same signal. That is §1.3's whole claim, and the
31
+ * assertion that proves it is a test showing tree-driven and graph-driven
32
+ * selection produce the identical highlight set.
33
+ *
34
+ * ## Tier rules (§2)
35
+ *
36
+ * `src/web/client/**`. Core view-models via `../../shared/view` — the one
37
+ * sanctioned door (§2.1.1), through which P3 brings `clusterAggregate`,
38
+ * `focusNeighborhood` and `degreeOf`. No DOM type is named and no npm package
39
+ * is imported, so the root `tsconfig.json` project compiles the tests.
40
+ */
41
+
42
+ import type { Point } from "../../shared/layout";
43
+ import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
44
+ import { clusterAggregate, degreeOf, focusNeighborhood } from "../../shared/view";
45
+ import type { GraphPayload, WireGraphEdge, WireGraphNode } from "../../shared/wire";
46
+ import { viewModel } from "../tree/tree.model";
47
+ import type { ColorScheme, RenderGraph } from "./graph.model";
48
+ import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
49
+ import type { PositionStorage } from "./positions";
50
+ import { resolveLayout } from "./positions";
51
+
52
+ // --- the view state ------------------------------------------------------------------
53
+
54
+ /** The graph column's state. Owned by the column; never on the context bus. */
55
+ export interface GraphViewState {
56
+ /**
57
+ * Cluster ids the user has expanded (§7.4).
58
+ *
59
+ * Passed straight to `clusterAggregate`, whose contract is that a node is
60
+ * visible when every containment ancestor between it and a root is
61
+ * expanded. An empty set is therefore the maximally collapsed view — the
62
+ * roots alone — which is the right first frame for a repository with
63
+ * thousands of files and the wrong one for a vault with nine notes. See
64
+ * {@link initialGraphView}.
65
+ */
66
+ readonly expanded: ReadonlySet<string>;
67
+ }
68
+
69
+ /**
70
+ * How many nodes a graph may have before it opens collapsed.
71
+ *
72
+ * Not a preference — a legibility bound. Above roughly this many, a
73
+ * force-directed layout of a containment tree is a disc of overlapping labels
74
+ * whichever way you draw it, and the honest first frame is the five clusters
75
+ * with their counts. Below it, collapsing hides a graph the user could simply
76
+ * have read.
77
+ *
78
+ * 120 is the point at which this repository's own fixture (89 nodes) still
79
+ * opens whole while a real `src/` scan (hundreds of files) does not.
80
+ */
81
+ export const AUTO_COLLAPSE_ABOVE = 120;
82
+
83
+ /**
84
+ * The state a freshly loaded graph opens in.
85
+ *
86
+ * Everything expanded for a small graph, nothing for a large one. `expanded`
87
+ * has to name the clusters explicitly rather than carry an "all" flag, because
88
+ * `clusterAggregate` takes a set and the first thing a user does is collapse
89
+ * one — which has to leave the others open.
90
+ */
91
+ export function initialGraphView(model: ViewGraphModel): GraphViewState {
92
+ const expanded =
93
+ model.nodes.length > AUTO_COLLAPSE_ABOVE ? new Set<string>() : new Set(clusterAggregate(model, new Set()).clusters.keys());
94
+ return { expanded };
95
+ }
96
+
97
+ /**
98
+ * The state to render with: the user's, or the default for this payload.
99
+ *
100
+ * The column holds `GraphViewState | null` and `null` means "the user has not
101
+ * touched the expansion yet" — which is not the same as "nothing is expanded",
102
+ * because {@link initialGraphView} opens a small graph whole. Resolving that
103
+ * here rather than in the component keeps a real branch out of a `.tsx` (§10)
104
+ * and, more importantly, makes the rule testable: a refetch must **not** reset
105
+ * an expansion the user has chosen, and the watcher fires on every file save.
106
+ */
107
+ export function effectiveView(payload: GraphPayload | null, state: GraphViewState | null): GraphViewState {
108
+ if (state !== null) return state;
109
+ if (payload === null) return { expanded: new Set() };
110
+ return initialGraphView(viewModel(payload));
111
+ }
112
+
113
+ /** Open or close one cluster. Returns a new state; see {@link expandAll}. */
114
+ export function toggleCluster(state: GraphViewState, id: string): GraphViewState {
115
+ const expanded = new Set(state.expanded);
116
+ if (!expanded.delete(id)) expanded.add(id);
117
+ return { ...state, expanded };
118
+ }
119
+
120
+ /**
121
+ * Open every cluster. The `[expand]` control from the §1.2 mock.
122
+ *
123
+ * `clusters` is `ClusterAggregate.clusters`, which holds **every** node with a
124
+ * containment child whether or not it is currently visible — so one press
125
+ * opens the whole tree rather than one level of it. That is deliberate: the
126
+ * per-level walk is what the tree column is for, and a graph control that
127
+ * needed six presses to show the graph would be a worse version of it.
128
+ */
129
+ export function expandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
130
+ return { ...state, expanded: new Set(clusters.keys()) };
131
+ }
132
+
133
+ /** Close every cluster, back to the roots. The other half of `[expand]`. */
134
+ export function collapseAll(state: GraphViewState): GraphViewState {
135
+ return { ...state, expanded: new Set() };
136
+ }
137
+
138
+ // --- the highlight (§1.3, §7.4) ---------------------------------------------------------
139
+
140
+ /**
141
+ * The set of ids that stay lit when `selectedId` is selected.
142
+ *
143
+ * `focusNeighborhood` **verbatim** — core's function, the same one the
144
+ * context rail's "Related" is built from, reached through the §2.1.1 door
145
+ * rather than reimplemented. (The `[depth 1 ▾]` control once let the user
146
+ * widen this to two and three hops; it read as decoration next to the rail's
147
+ * fixed-depth "Related" and is gone — one definition of "related", one
148
+ * behaviour everywhere.)
149
+ *
150
+ * `null` in, `null` out: nothing selected means no highlight at all, which the
151
+ * reducers treat as "render everything normally" — deliberately not the same
152
+ * as an empty set (see `graph.model.ts`).
153
+ *
154
+ * Computed over the **currently visible** edges, not the full model. A
155
+ * neighbour inside a collapsed cluster is not on screen, and `clusterAggregate`
156
+ * has already retargeted the edge onto the cluster standing in for it — so
157
+ * highlighting the hidden id would light nothing while leaving the cluster
158
+ * that actually represents it dimmed.
159
+ */
160
+ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null): Set<string> | null {
161
+ if (selectedId === null) return null;
162
+ return focusNeighborhood(selectedId, edges);
163
+ }
164
+
165
+ // --- the control strip (§1.2) ---------------------------------------------------------------
166
+
167
+ /** The `[expand]` control's label, which is really a toggle. */
168
+ export function expandLabel(allExpanded: boolean): string {
169
+ return allExpanded ? "collapse" : "expand";
170
+ }
171
+
172
+ /** Its tooltip. */
173
+ export function expandHint(allExpanded: boolean): string {
174
+ return allExpanded ? "collapse every cluster back to the roots" : "expand every cluster";
175
+ }
176
+
177
+ /** Whether every cluster in the graph is currently open. */
178
+ export function allExpanded(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): boolean {
179
+ if (clusters.size === 0) return false;
180
+ for (const id of clusters.keys()) if (!state.expanded.has(id)) return false;
181
+ return true;
182
+ }
183
+
184
+ /**
185
+ * What the `[expand]` control does when pressed.
186
+ *
187
+ * One function rather than a ternary in the component, for §10's reason: the
188
+ * choice between expanding and collapsing is a branch, and a branch in a
189
+ * `.tsx` is a branch no test can reach. It is also the *only* place the
190
+ * toggle's two halves are paired, so its label and its effect cannot disagree
191
+ * — {@link expandLabel} reads the same predicate.
192
+ */
193
+ export function toggleExpandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
194
+ return allExpanded(state, clusters) ? collapseAll(state) : expandAll(state, clusters);
195
+ }
196
+
197
+ /** The `[fit]` control. Constant, but named here so the component holds no copy. */
198
+ export const FIT_LABEL = "fit";
199
+ export const FIT_HINT = "frame the whole graph";
200
+
201
+ /**
202
+ * The legend under the canvas, from the §1.2 mock:
203
+ * `◉ selected ● neighborhood`.
204
+ */
205
+ export const LEGEND = { selected: "selected", neighborhood: "neighborhood", dimmed: "not related" } as const;
206
+
207
+ /**
208
+ * The count line: what is on screen out of what exists.
209
+ *
210
+ * Both numbers, always. "89 nodes" next to a canvas showing 5 is the reading
211
+ * that makes a collapsed graph look broken; "5 of 89 nodes" makes it obvious
212
+ * that there is more behind the clusters.
213
+ */
214
+ export function graphCountLabel(visible: number, total: number): string {
215
+ const noun = total === 1 ? "node" : "nodes";
216
+ return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`;
217
+ }
218
+
219
+ /** What the column says instead of a canvas. `null` means "there is a graph". */
220
+ export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
221
+ if (payload === null) return "Loading…";
222
+ if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan.";
223
+ // A payload with nodes but nothing visible cannot happen through the UI
224
+ // (`clusterAggregate` always keeps the roots), but a truncated response
225
+ // could produce it, and a blank canvas with no explanation reads as a crash.
226
+ return visible === 0 ? "Nothing to draw at this expansion." : null;
227
+ }
228
+
229
+ // --- the whole column state -----------------------------------------------------------------
230
+
231
+ /** Everything the column needs to render one frame. */
232
+ export interface GraphColumnModel {
233
+ /** What sigma draws. */
234
+ readonly graph: RenderGraph;
235
+ /** What the reducers dim outside of. `null` when nothing is selected. */
236
+ readonly highlight: Set<string> | null;
237
+ /** The layout's cache key, so the caller can tell one shape from another. */
238
+ readonly key: string;
239
+ /** True when the layout came from `localStorage` rather than the simulation. */
240
+ readonly cached: boolean;
241
+ /** Every cluster in the *unreduced* model — what `[expand]` operates on. */
242
+ readonly clusters: ReadonlyMap<string, ClusterInfo>;
243
+ /** Visible / total, for the count line. */
244
+ readonly visible: number;
245
+ readonly total: number;
246
+ /** Non-null when there is nothing to draw. */
247
+ readonly empty: string | null;
248
+ }
249
+
250
+ /** The column with nothing in it. What the first render produces. */
251
+ export const EMPTY_COLUMN: GraphColumnModel = {
252
+ graph: EMPTY_RENDER_GRAPH,
253
+ highlight: null,
254
+ key: "",
255
+ cached: false,
256
+ clusters: new Map(),
257
+ visible: 0,
258
+ total: 0,
259
+ empty: "Loading…",
260
+ };
261
+
262
+ /**
263
+ * Reduce, lay out, resolve and highlight — one frame of the graph column.
264
+ *
265
+ * The order is the design, and each step depends on the one before it:
266
+ *
267
+ * 1. **Reduce** (`clusterAggregate`, core). Collapsed subtrees become their
268
+ * parent and boundary-crossing edges are retargeted onto it. The renderer
269
+ * is never handed a node it is expected to hide (§7.4).
270
+ * 2. **Lay out** the *reduced* graph. Laying out the full model and then
271
+ * hiding nodes would leave the visible ones spread across the holes the
272
+ * hidden ones left — the collapsed view would have the geometry of the
273
+ * expanded one, which is the opposite of the point.
274
+ * 3. **Resolve** to a `RenderGraph`.
275
+ * 4. **Highlight** over the reduced edges, so a neighbour inside a collapsed
276
+ * cluster lights the cluster rather than nothing.
277
+ *
278
+ * Step 2 is also why the layout cache is keyed by shape: every expand and
279
+ * collapse produces a genuinely different graph, gets its own key, and is
280
+ * remembered independently — so collapsing and re-expanding returns the
281
+ * arrangement the user had, rather than a fresh one.
282
+ */
283
+ export function graphColumnModel(
284
+ payload: GraphPayload | null,
285
+ selectedId: string | null,
286
+ state: GraphViewState,
287
+ storage: PositionStorage,
288
+ scheme: ColorScheme,
289
+ ): GraphColumnModel {
290
+ if (payload === null) return EMPTY_COLUMN;
291
+
292
+ const model = viewModel(payload);
293
+ const reduced: ClusterAggregate = clusterAggregate(model, state.expanded);
294
+ const layout = resolveLayout(storage, reduced.nodes, reduced.edges);
295
+
296
+ return {
297
+ graph: renderGraph(reduced.nodes, reduced.edges, layout.positions, scheme),
298
+ highlight: highlightFor(reduced.edges, selectedId),
299
+ key: layout.key,
300
+ cached: layout.cached,
301
+ clusters: reduced.clusters,
302
+ visible: reduced.nodes.length,
303
+ total: model.nodes.length,
304
+ empty: graphEmptyMessage(payload, reduced.nodes.length),
305
+ };
306
+ }
307
+
308
+ // --- clicking a node -------------------------------------------------------------------------
309
+
310
+ /** What a click on the canvas produced. */
311
+ export interface GraphClick {
312
+ /** The next view state — changed only when a collapsed cluster was opened. */
313
+ readonly state: GraphViewState;
314
+ /** What to write to `selectedId`. Always set, even when a cluster expanded. */
315
+ readonly selectedId: string | null;
316
+ }
317
+
318
+ /**
319
+ * Apply a click on a node (or on empty stage, with `null`).
320
+ *
321
+ * A click does **two** things when it lands on a collapsed cluster: it selects
322
+ * it *and* it expands it (§7.4, "expand on click"). Both, deliberately — the
323
+ * selection is what drives the note column and the context rail, and dropping
324
+ * it in favour of the expansion would make clicking a module the one gesture
325
+ * in the workspace that does not update the other columns.
326
+ *
327
+ * A click on an already-expanded cluster selects without collapsing. Collapse
328
+ * is the `[expand]` control and the tree; making a second click close a
329
+ * cluster would make it impossible to select one and read it.
330
+ */
331
+ export function graphClick(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>, id: string | null): GraphClick {
332
+ if (id === null) return { state, selectedId: null };
333
+ const cluster = clusters.get(id);
334
+ if (cluster === undefined || state.expanded.has(id)) return { state, selectedId: id };
335
+ return { state: toggleCluster(state, id), selectedId: id };
336
+ }
337
+
338
+ // --- tooltips ---------------------------------------------------------------------------------
339
+
340
+ /**
341
+ * What a node says when it stands in for a collapsed subtree.
342
+ *
343
+ * `ClusterInfo.members` is exactly the hidden descendants this cluster is
344
+ * currently representing — not `descendants`, which overlaps freely between a
345
+ * cluster and its ancestors and would report the same file under both
346
+ * `src/core` and `repository`. `members` partitions the hidden set across the
347
+ * visible clusters, so the counts add up to the number of nodes that are not
348
+ * on screen.
349
+ */
350
+ export function clusterBadge(cluster: ClusterInfo | undefined): string | null {
351
+ if (cluster === undefined || cluster.members.length === 0) return null;
352
+ return cluster.members.length === 1 ? "1 hidden" : `${cluster.members.length} hidden`;
353
+ }
354
+
355
+ /**
356
+ * A node's hover text: its label, its degree, and what it is standing in for.
357
+ *
358
+ * `degreeOf` is core's, reached through the §2.1.1 door. The bulk `degrees`
359
+ * pass in `graph.model.ts` is a different algorithm for a different question
360
+ * (every node at once, O(edges)); this is one node, which is what a tooltip
361
+ * asks.
362
+ */
363
+ export function nodeTooltip(node: WireGraphNode, edges: readonly WireGraphEdge[], cluster: ClusterInfo | undefined): string {
364
+ const degree = degreeOf(node.id, edges);
365
+ const parts = [node.label, degree === 1 ? "1 link" : `${degree} links`];
366
+ const badge = clusterBadge(cluster);
367
+ if (badge !== null) parts.push(badge);
368
+ return parts.join(" · ");
369
+ }
370
+
371
+ /** Positions keyed by id, for a caller warm-starting a re-run. */
372
+ export type LayoutSnapshot = ReadonlyMap<string, Point>;