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.
- package/README.md +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- 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>;
|