pi-weave 0.1.7 → 0.1.8
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 +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- 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/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- 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 +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -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 +279 -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/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 +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -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 +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -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 +206 -0
- package/src/web/shared/layout.ts +497 -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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clusterAggregate — collapse containment subtrees into their parent node
|
|
3
|
+
* (weave-workspace §3, §7.4).
|
|
4
|
+
*
|
|
5
|
+
* A repository graph is mostly containment: a handful of roots, then modules,
|
|
6
|
+
* then hundreds or thousands of files. Drawn flat that is a hairball, and
|
|
7
|
+
* scrolled flat it is a wall. The fix is *graph reduction*, not rendering:
|
|
8
|
+
* given the model and the set of ids the user has expanded, produce a smaller
|
|
9
|
+
* graph where every collapsed subtree is represented by the node that contains
|
|
10
|
+
* it, with the edges that crossed the boundary retargeted onto that node.
|
|
11
|
+
*
|
|
12
|
+
* Because it is reduction, it lives in core and is shared: the sigma graph
|
|
13
|
+
* panel (§7.4) consumes `nodes`/`edges` directly, and the TUI tree can consume
|
|
14
|
+
* the same `clusters` map for its child counts and provenance rollups. One
|
|
15
|
+
* implementation, so the two surfaces cannot disagree about what "collapsed"
|
|
16
|
+
* means.
|
|
17
|
+
*
|
|
18
|
+
* ## Relationship to the retired viewer
|
|
19
|
+
*
|
|
20
|
+
* The retired `page.ts` had a function of the same name. It computed a
|
|
21
|
+
* *visibility mask* — every node stayed in the graph and hidden ones were
|
|
22
|
+
* given `display: none` — and it never rewrote a single edge, so a link from a
|
|
23
|
+
* hidden file to a visible note simply vanished. Two deliberate changes:
|
|
24
|
+
*
|
|
25
|
+
* 1. **Reduction, not masking.** We return the reduced node and edge arrays.
|
|
26
|
+
* A renderer should never be handed nodes it is expected to hide, and
|
|
27
|
+
* sigma in particular wants a graph it can consume verbatim (§7.1).
|
|
28
|
+
* 2. **Strict collapse.** The retired `reveal` recursed into *every* child
|
|
29
|
+
* cluster regardless of the expand set, gating only leaves — so collapsing
|
|
30
|
+
* `src/core` still drew all of its sub-clusters. That contradicts both the
|
|
31
|
+
* word "collapse" and {@link treeRows}, which recurses only into expanded
|
|
32
|
+
* nodes. Here a collapsed cluster hides its entire subtree, so the TUI tree
|
|
33
|
+
* and the web graph answer "what is visible?" identically.
|
|
34
|
+
*
|
|
35
|
+
* Retained from the original: containment means `contains` **or**
|
|
36
|
+
* `anchored-at`; clusters are real model nodes rather than synthetic ones (so
|
|
37
|
+
* ids stay stable and selection keeps working); and the per-cluster provenance
|
|
38
|
+
* rollup over all descendants.
|
|
39
|
+
*
|
|
40
|
+
* The original's `expandChildren` / `collapseChildren` set builders are *not*
|
|
41
|
+
* ported. They only existed to walk the model a second time, which
|
|
42
|
+
* {@link ClusterInfo.descendants} already did, and each is now one expression
|
|
43
|
+
* on the caller's side:
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const under = agg.clusters.get(id)!.descendants.filter((d) => agg.clusters.has(d));
|
|
47
|
+
* const expandAll = new Set([...expanded, id, ...under]);
|
|
48
|
+
* const collapse = new Set([...expanded].filter((e) => e !== id && !under.includes(e)));
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "../graph/model";
|
|
53
|
+
import type { NoteSource } from "../types";
|
|
54
|
+
import { graphRoots } from "./tree";
|
|
55
|
+
|
|
56
|
+
/** The edge kinds that form the nesting hierarchy. Matches `treeRows`. */
|
|
57
|
+
const CONTAINMENT: readonly EdgeKind[] = ["contains", "anchored-at"];
|
|
58
|
+
|
|
59
|
+
function isContainment(kind: EdgeKind): boolean {
|
|
60
|
+
return CONTAINMENT.includes(kind);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Provenance rollup over a cluster's descendants (the ring / mini-bar source). */
|
|
64
|
+
export interface ProvenanceSplit {
|
|
65
|
+
human: number;
|
|
66
|
+
agent: number;
|
|
67
|
+
generated: number;
|
|
68
|
+
/** Descendants with no provenance — modules, files, git state, packages. */
|
|
69
|
+
none: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What is known about one cluster: any node with at least one containment child. */
|
|
73
|
+
export interface ClusterInfo {
|
|
74
|
+
/** Direct containment children, de-duplicated, in model edge order. */
|
|
75
|
+
readonly children: readonly string[];
|
|
76
|
+
/** Every transitive containment descendant, de-duplicated, depth-first. */
|
|
77
|
+
readonly descendants: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* The hidden descendants this cluster is currently standing in for — the
|
|
80
|
+
* cluster → members direction of the mapping, and what a "12 files" badge
|
|
81
|
+
* counts.
|
|
82
|
+
*
|
|
83
|
+
* Empty unless the cluster is *both* visible and collapsed: an expanded
|
|
84
|
+
* cluster hides nothing, and a cluster that is itself hidden is already
|
|
85
|
+
* represented by an ancestor. So `members` partitions {@link
|
|
86
|
+
* ClusterAggregate.hidden} across the visible clusters, whereas
|
|
87
|
+
* {@link descendants} overlaps freely between a cluster and its ancestors.
|
|
88
|
+
*/
|
|
89
|
+
readonly members: readonly string[];
|
|
90
|
+
/** Provenance counts over {@link descendants} (the cluster itself excluded). */
|
|
91
|
+
readonly provenance: ProvenanceSplit;
|
|
92
|
+
/** Whether `expanded` contained this id. */
|
|
93
|
+
readonly expanded: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ClusterAggregate {
|
|
97
|
+
/** The visible nodes, in model order. A subset of `model.nodes`. */
|
|
98
|
+
nodes: GraphNode[];
|
|
99
|
+
/**
|
|
100
|
+
* Edges between visible nodes, in model order. Endpoints inside a collapsed
|
|
101
|
+
* cluster are retargeted onto that cluster; the resulting duplicates and
|
|
102
|
+
* self-loops are dropped.
|
|
103
|
+
*/
|
|
104
|
+
edges: GraphEdge[];
|
|
105
|
+
/**
|
|
106
|
+
* Every node with at least one containment child, whether or not it is
|
|
107
|
+
* visible or expanded — a caller implementing "expand all" filters
|
|
108
|
+
* `descendants` through this map.
|
|
109
|
+
*/
|
|
110
|
+
clusters: Map<string, ClusterInfo>;
|
|
111
|
+
/** Ids not present in {@link nodes}. */
|
|
112
|
+
hidden: Set<string>;
|
|
113
|
+
/**
|
|
114
|
+
* Hidden id → the visible cluster representing it. The member → cluster
|
|
115
|
+
* direction of the mapping; the inverse of {@link ClusterInfo.members}.
|
|
116
|
+
*/
|
|
117
|
+
representative: Map<string, string>;
|
|
118
|
+
/** Containment roots, in model order (always visible). */
|
|
119
|
+
roots: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reduce `model` to the subgraph implied by `expanded`.
|
|
124
|
+
*
|
|
125
|
+
* A node is visible when every containment ancestor between it and a root is
|
|
126
|
+
* expanded. Roots are always visible, so an empty `expanded` set yields the
|
|
127
|
+
* roots alone — the maximally collapsed view.
|
|
128
|
+
*
|
|
129
|
+
* Deterministic for a deterministic model: every output is ordered by the
|
|
130
|
+
* model's own node and edge order, and a node reachable through more than one
|
|
131
|
+
* parent is attributed to the first one encountered.
|
|
132
|
+
*
|
|
133
|
+
* Defensive on malformed input, which core cannot rule out for a hand-written
|
|
134
|
+
* or partially-rebuilt `.okf` index: containment cycles terminate, and an edge
|
|
135
|
+
* naming an id that is not in the node set is dropped rather than emitted (a
|
|
136
|
+
* renderer given an edge to a non-existent node throws).
|
|
137
|
+
*/
|
|
138
|
+
export function clusterAggregate(model: GraphModel, expanded: ReadonlySet<string>): ClusterAggregate {
|
|
139
|
+
const byId = new Map<string, GraphNode>();
|
|
140
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
141
|
+
|
|
142
|
+
const children = indexChildren(model, byId);
|
|
143
|
+
const roots = graphRoots(model);
|
|
144
|
+
|
|
145
|
+
// Visible = reachable from a root without passing through a collapsed node.
|
|
146
|
+
const visible = new Set<string>();
|
|
147
|
+
const reveal = (id: string): void => {
|
|
148
|
+
if (visible.has(id)) return; // also the cycle guard
|
|
149
|
+
visible.add(id);
|
|
150
|
+
if (!expanded.has(id)) return;
|
|
151
|
+
for (const child of children.get(id) ?? []) reveal(child);
|
|
152
|
+
};
|
|
153
|
+
for (const r of roots) reveal(r);
|
|
154
|
+
|
|
155
|
+
// Descendants, computed once per cluster and reused for members + provenance.
|
|
156
|
+
const descendantsOf = new Map<string, string[]>();
|
|
157
|
+
for (const id of children.keys()) descendantsOf.set(id, descendants(id, children));
|
|
158
|
+
|
|
159
|
+
// Hidden id → nearest visible ancestor. Visible collapsed clusters are the
|
|
160
|
+
// only things that can represent anything; walking them in model order and
|
|
161
|
+
// never overwriting makes multi-parent attribution deterministic.
|
|
162
|
+
const representative = new Map<string, string>();
|
|
163
|
+
for (const n of model.nodes) {
|
|
164
|
+
if (!visible.has(n.id) || expanded.has(n.id)) continue;
|
|
165
|
+
for (const d of descendantsOf.get(n.id) ?? []) {
|
|
166
|
+
if (!visible.has(d) && !representative.has(d)) representative.set(d, n.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A containment cycle with no root above it is reachable from nowhere, so it
|
|
171
|
+
// is neither visible nor represented. Show it: dropping it silently would
|
|
172
|
+
// lose data the caller has no other way to see.
|
|
173
|
+
for (const n of model.nodes) {
|
|
174
|
+
if (!visible.has(n.id) && !representative.has(n.id)) visible.add(n.id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const clusters = new Map<string, ClusterInfo>();
|
|
178
|
+
for (const [id, kids] of children) {
|
|
179
|
+
// Populated from `children.keys()` immediately above, so this is total.
|
|
180
|
+
const desc = descendantsOf.get(id)!;
|
|
181
|
+
clusters.set(id, {
|
|
182
|
+
children: kids,
|
|
183
|
+
descendants: desc,
|
|
184
|
+
members: desc.filter((d) => representative.get(d) === id),
|
|
185
|
+
provenance: splitOf(desc, byId),
|
|
186
|
+
expanded: expanded.has(id),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hidden = new Set<string>();
|
|
191
|
+
const nodes: GraphNode[] = [];
|
|
192
|
+
for (const n of model.nodes) {
|
|
193
|
+
if (visible.has(n.id)) nodes.push(n);
|
|
194
|
+
else hidden.add(n.id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { nodes, edges: rewrite(model.edges, visible, representative), clusters, hidden, representative, roots };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Direct containment children per parent, de-duplicated, in model edge order. */
|
|
201
|
+
function indexChildren(model: GraphModel, byId: Map<string, GraphNode>): Map<string, string[]> {
|
|
202
|
+
const out = new Map<string, string[]>();
|
|
203
|
+
for (const e of model.edges) {
|
|
204
|
+
if (!isContainment(e.kind)) continue;
|
|
205
|
+
// An edge to an id that is not a node cannot be collapsed into anything.
|
|
206
|
+
if (!byId.has(e.source) || !byId.has(e.target)) continue;
|
|
207
|
+
const list = out.get(e.source);
|
|
208
|
+
if (list === undefined) out.set(e.source, [e.target]);
|
|
209
|
+
else if (!list.includes(e.target)) list.push(e.target);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Transitive containment descendants of `id`, depth-first, cycle-safe. */
|
|
215
|
+
function descendants(id: string, children: Map<string, string[]>): string[] {
|
|
216
|
+
const out: string[] = [];
|
|
217
|
+
const seen = new Set<string>([id]);
|
|
218
|
+
const walk = (at: string): void => {
|
|
219
|
+
for (const child of children.get(at) ?? []) {
|
|
220
|
+
if (seen.has(child)) continue;
|
|
221
|
+
seen.add(child);
|
|
222
|
+
out.push(child);
|
|
223
|
+
walk(child);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
walk(id);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function splitOf(ids: readonly string[], byId: Map<string, GraphNode>): ProvenanceSplit {
|
|
231
|
+
const split: ProvenanceSplit = { human: 0, agent: 0, generated: 0, none: 0 };
|
|
232
|
+
for (const id of ids) {
|
|
233
|
+
const provenance: NoteSource | null = byId.get(id)?.provenance ?? null;
|
|
234
|
+
if (provenance === null) split.none++;
|
|
235
|
+
else split[provenance]++;
|
|
236
|
+
}
|
|
237
|
+
return split;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Retarget each edge onto visible endpoints, then drop the self-loops (both
|
|
242
|
+
* ends fell into the same cluster) and the duplicates (many crossings of the
|
|
243
|
+
* same boundary collapse to one). Kind is preserved and participates in the
|
|
244
|
+
* de-duplication, so a `contains` and a `links-to` between the same pair both
|
|
245
|
+
* survive.
|
|
246
|
+
*/
|
|
247
|
+
function rewrite(edges: readonly GraphEdge[], visible: ReadonlySet<string>, representative: ReadonlyMap<string, string>): GraphEdge[] {
|
|
248
|
+
const resolve = (id: string): string | undefined => (visible.has(id) ? id : representative.get(id));
|
|
249
|
+
const out: GraphEdge[] = [];
|
|
250
|
+
const seen = new Set<string>();
|
|
251
|
+
for (const e of edges) {
|
|
252
|
+
const source = resolve(e.source);
|
|
253
|
+
const target = resolve(e.target);
|
|
254
|
+
if (source === undefined || target === undefined) continue;
|
|
255
|
+
if (source === target) continue;
|
|
256
|
+
const key = `${source}\u0000${target}\u0000${e.kind}`;
|
|
257
|
+
if (seen.has(key)) continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
out.push({ source, target, kind: e.kind });
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detailModel — a selected node's meta + links + backlinks (weave-workspace §3).
|
|
3
|
+
*
|
|
4
|
+
* The note body is *not* part of this model: it is loaded async by whichever
|
|
5
|
+
* renderer needs it (`readNoteForView` / `readOkfFileForView`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphModel, GraphNode, NodeKind } from "../graph/model";
|
|
9
|
+
import type { NoteSource } from "../types";
|
|
10
|
+
import { listLabel } from "./tree";
|
|
11
|
+
import type { SelectableRow } from "./types";
|
|
12
|
+
|
|
13
|
+
export interface DetailMetaRow extends SelectableRow {
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DetailLinkRow extends SelectableRow {
|
|
19
|
+
label: string;
|
|
20
|
+
kind: NodeKind;
|
|
21
|
+
provenance: NoteSource | null;
|
|
22
|
+
/** "link" (outgoing) or "backlink" (incoming). */
|
|
23
|
+
direction: "link" | "backlink";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DetailModel {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
kind: NodeKind;
|
|
30
|
+
provenance: NoteSource | null;
|
|
31
|
+
meta: DetailMetaRow[];
|
|
32
|
+
links: DetailLinkRow[];
|
|
33
|
+
backlinks: DetailLinkRow[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ordered meta keys shown in the detail header (weave-view-tui-design §5.2). */
|
|
37
|
+
const META_ORDER = [
|
|
38
|
+
"path",
|
|
39
|
+
"slug",
|
|
40
|
+
"source",
|
|
41
|
+
"updated",
|
|
42
|
+
"created",
|
|
43
|
+
"tags",
|
|
44
|
+
"files",
|
|
45
|
+
"languages",
|
|
46
|
+
"branch",
|
|
47
|
+
"commit",
|
|
48
|
+
"uncommitted changes",
|
|
49
|
+
"captured",
|
|
50
|
+
"manifest",
|
|
51
|
+
"kind",
|
|
52
|
+
"url",
|
|
53
|
+
"summarized files",
|
|
54
|
+
"summarized by",
|
|
55
|
+
"summarized at",
|
|
56
|
+
"summary",
|
|
57
|
+
"dangling links",
|
|
58
|
+
"warning",
|
|
59
|
+
"stale",
|
|
60
|
+
"preview",
|
|
61
|
+
"root",
|
|
62
|
+
"notes",
|
|
63
|
+
"state",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
export function detailModel(model: GraphModel, id: string): DetailModel | null {
|
|
67
|
+
const byId = new Map<string, GraphNode>();
|
|
68
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
69
|
+
const node = byId.get(id);
|
|
70
|
+
if (!node) return null;
|
|
71
|
+
|
|
72
|
+
const meta: DetailMetaRow[] = [];
|
|
73
|
+
for (const key of META_ORDER) {
|
|
74
|
+
const v = node.detail[key];
|
|
75
|
+
if (v === undefined || v === "") continue;
|
|
76
|
+
meta.push({ id: `meta:${key}`, label: key, value: v });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const links: DetailLinkRow[] = [];
|
|
80
|
+
for (const e of model.edges) {
|
|
81
|
+
if (e.source !== id) continue;
|
|
82
|
+
const t = byId.get(e.target);
|
|
83
|
+
if (!t) continue;
|
|
84
|
+
links.push({
|
|
85
|
+
id: `link:${e.kind}:${e.target}`,
|
|
86
|
+
target: e.target,
|
|
87
|
+
label: `${e.kind} → ${listLabel(t)}`,
|
|
88
|
+
kind: t.kind,
|
|
89
|
+
provenance: t.provenance,
|
|
90
|
+
direction: "link",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const backlinks: DetailLinkRow[] = [];
|
|
95
|
+
for (const e of model.edges) {
|
|
96
|
+
if (e.kind !== "links-to" || e.target !== id) continue;
|
|
97
|
+
const s = byId.get(e.source);
|
|
98
|
+
if (!s) continue;
|
|
99
|
+
backlinks.push({
|
|
100
|
+
id: `backlink:${e.source}`,
|
|
101
|
+
target: e.source,
|
|
102
|
+
label: `← ${listLabel(s)}`,
|
|
103
|
+
kind: s.kind,
|
|
104
|
+
provenance: s.provenance,
|
|
105
|
+
direction: "backlink",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
label: listLabel(node),
|
|
112
|
+
kind: node.kind,
|
|
113
|
+
provenance: node.provenance,
|
|
114
|
+
meta,
|
|
115
|
+
links,
|
|
116
|
+
backlinks,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* focusModel — the 1-hop neighborhood of a node, grouped (weave-workspace §3).
|
|
3
|
+
*
|
|
4
|
+
* The TUI renders this as the Focus surface; the browser workspace uses
|
|
5
|
+
* `focusNeighborhood` for the graph's neighborhood highlight.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../graph/model";
|
|
9
|
+
import type { NoteSource } from "../types";
|
|
10
|
+
import { listLabel } from "./tree";
|
|
11
|
+
import type { SelectableRow } from "./types";
|
|
12
|
+
|
|
13
|
+
/** 1-hop neighborhood of `id` (the node itself + direct neighbors). 2-hop excluded. */
|
|
14
|
+
export function focusNeighborhood(id: string, edges: readonly GraphEdge[]): Set<string> {
|
|
15
|
+
const out = new Set<string>([id]);
|
|
16
|
+
for (const e of edges) {
|
|
17
|
+
if (e.source === id) out.add(e.target);
|
|
18
|
+
if (e.target === id) out.add(e.source);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Incident-edge degree (both directions, all kinds). */
|
|
24
|
+
export function degreeOf(id: string, edges: readonly GraphEdge[]): number {
|
|
25
|
+
let d = 0;
|
|
26
|
+
for (const e of edges) if (e.source === id || e.target === id) d++;
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FocusRow extends SelectableRow {
|
|
31
|
+
label: string;
|
|
32
|
+
kind: NodeKind;
|
|
33
|
+
provenance: NoteSource | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FocusGroup {
|
|
37
|
+
/** Heading label, e.g. "links to →", "← linked from", "contains", "contained by". */
|
|
38
|
+
heading: string;
|
|
39
|
+
rows: FocusRow[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FocusModel {
|
|
43
|
+
center: FocusRow;
|
|
44
|
+
groups: FocusGroup[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The outgoing edge kinds that get their own group, in display order. */
|
|
48
|
+
const OUTGOING_KINDS = ["links-to", "contains", "anchored-at"] as const;
|
|
49
|
+
|
|
50
|
+
const OUTGOING_HEADINGS: Record<(typeof OUTGOING_KINDS)[number], string> = {
|
|
51
|
+
"links-to": "links to →",
|
|
52
|
+
contains: "contains",
|
|
53
|
+
"anchored-at": "anchored at",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Group the 1-hop neighborhood of `id` (outgoing by kind, then incoming). */
|
|
57
|
+
export function focusModel(model: GraphModel, id: string): FocusModel {
|
|
58
|
+
const byId = new Map<string, GraphNode>();
|
|
59
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
60
|
+
|
|
61
|
+
const centerNode = byId.get(id);
|
|
62
|
+
const center: FocusRow = centerNode
|
|
63
|
+
? rowFromNode(centerNode)
|
|
64
|
+
: { id, label: id, kind: "note", provenance: null };
|
|
65
|
+
|
|
66
|
+
const outByKind = new Map<string, string[]>();
|
|
67
|
+
const incomingContains = new Map<string, string[]>();
|
|
68
|
+
const backlinks = new Map<string, string[]>();
|
|
69
|
+
for (const e of model.edges) {
|
|
70
|
+
if (e.source === id) {
|
|
71
|
+
const list = outByKind.get(e.kind);
|
|
72
|
+
if (list) list.push(e.target);
|
|
73
|
+
else outByKind.set(e.kind, [e.target]);
|
|
74
|
+
} else if (e.target === id) {
|
|
75
|
+
if (e.kind === "links-to") {
|
|
76
|
+
const list = backlinks.get(e.kind);
|
|
77
|
+
if (list) list.push(e.source);
|
|
78
|
+
else backlinks.set(e.kind, [e.source]);
|
|
79
|
+
} else if (e.kind === "contains" || e.kind === "anchored-at") {
|
|
80
|
+
const list = incomingContains.get(e.kind);
|
|
81
|
+
if (list) list.push(e.source);
|
|
82
|
+
else incomingContains.set(e.kind, [e.source]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const toRows = (ids: string[]): FocusRow[] =>
|
|
88
|
+
ids
|
|
89
|
+
.map((x) => byId.get(x))
|
|
90
|
+
.filter((n): n is GraphNode => n !== undefined)
|
|
91
|
+
.map(rowFromNode);
|
|
92
|
+
|
|
93
|
+
const groups: FocusGroup[] = [];
|
|
94
|
+
for (const kind of OUTGOING_KINDS) {
|
|
95
|
+
const ids = outByKind.get(kind) ?? [];
|
|
96
|
+
if (ids.length === 0) continue;
|
|
97
|
+
groups.push({ heading: OUTGOING_HEADINGS[kind], rows: toRows(ids) });
|
|
98
|
+
}
|
|
99
|
+
const bl = backlinks.get("links-to") ?? [];
|
|
100
|
+
if (bl.length > 0) groups.push({ heading: "← linked from", rows: toRows(bl) });
|
|
101
|
+
const containedBy = incomingContains.get("contains") ?? incomingContains.get("anchored-at") ?? [];
|
|
102
|
+
if (containedBy.length > 0) groups.push({ heading: "contained by", rows: toRows(containedBy) });
|
|
103
|
+
|
|
104
|
+
return { center, groups };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rowFromNode(n: GraphNode): FocusRow {
|
|
108
|
+
return { id: n.id, target: n.id, label: listLabel(n), kind: n.kind, provenance: n.provenance };
|
|
109
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* healthModel — staleness + link health, derived exclusively from the
|
|
3
|
+
* GraphModel (weave-view-tui-design §5.4 / weave-workspace §3).
|
|
4
|
+
*
|
|
5
|
+
* Zero new server/core fields: everything here is a projection of nodes,
|
|
6
|
+
* edges, and `model.staleness`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { GraphModel, GraphNode } from "../graph/model";
|
|
10
|
+
import { deriveBacklinks } from "./links";
|
|
11
|
+
import { listLabel } from "./tree";
|
|
12
|
+
import type { SelectableRow } from "./types";
|
|
13
|
+
|
|
14
|
+
export interface HealthRow extends SelectableRow {
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HealthSection {
|
|
19
|
+
heading: string;
|
|
20
|
+
rows: HealthRow[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HealthModel {
|
|
24
|
+
sections: HealthSection[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HEALTH_LIST_CAP = 10;
|
|
28
|
+
|
|
29
|
+
export function healthModel(model: GraphModel): HealthModel {
|
|
30
|
+
const byId = new Map<string, GraphNode>();
|
|
31
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
32
|
+
const sections: HealthSection[] = [];
|
|
33
|
+
|
|
34
|
+
// Repository section
|
|
35
|
+
const repo = byId.get("repository");
|
|
36
|
+
if (repo) {
|
|
37
|
+
const rows: HealthRow[] = [];
|
|
38
|
+
const staleness = model.staleness;
|
|
39
|
+
if (staleness) {
|
|
40
|
+
rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
|
|
41
|
+
for (let i = 0; i < staleness.reasons.length; i++) {
|
|
42
|
+
rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
|
|
46
|
+
if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
|
|
47
|
+
let summarized = 0;
|
|
48
|
+
for (const n of model.nodes) {
|
|
49
|
+
if (n.kind !== "module") continue;
|
|
50
|
+
const count = n.detail["summarized files"];
|
|
51
|
+
if (count) summarized += Number(count);
|
|
52
|
+
}
|
|
53
|
+
if (summarized > 0) {
|
|
54
|
+
rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
|
|
55
|
+
}
|
|
56
|
+
sections.push({ heading: "Repository", rows });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Vault section
|
|
60
|
+
const vault = byId.get("vault");
|
|
61
|
+
if (vault) {
|
|
62
|
+
const rows: HealthRow[] = [];
|
|
63
|
+
const noteCount = Number(vault.detail.notes ?? "0");
|
|
64
|
+
rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
|
|
65
|
+
const prov = countProvenance(model.nodes);
|
|
66
|
+
rows.push({
|
|
67
|
+
id: "health:vault:provenance",
|
|
68
|
+
text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
|
|
69
|
+
});
|
|
70
|
+
if (vault.detail.warning) {
|
|
71
|
+
rows.push({ id: "health:vault:warning", text: vault.detail.warning });
|
|
72
|
+
}
|
|
73
|
+
sections.push({ heading: "Vault", rows });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Link health
|
|
77
|
+
const backlinks = deriveBacklinks(model.edges);
|
|
78
|
+
const orphans: GraphNode[] = [];
|
|
79
|
+
const dangling: { node: GraphNode; count: number }[] = [];
|
|
80
|
+
for (const n of model.nodes) {
|
|
81
|
+
if (n.kind !== "note") continue;
|
|
82
|
+
if (!backlinks.has(n.id)) orphans.push(n);
|
|
83
|
+
const dl = n.detail["dangling links"];
|
|
84
|
+
if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
|
|
85
|
+
}
|
|
86
|
+
const degree = new Map<string, number>();
|
|
87
|
+
for (const e of model.edges) {
|
|
88
|
+
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
89
|
+
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
const hubs = [...model.nodes]
|
|
92
|
+
.map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
|
|
93
|
+
.filter((x) => x.d > 0)
|
|
94
|
+
.sort((a, b) => b.d - a.d)
|
|
95
|
+
.slice(0, HEALTH_LIST_CAP);
|
|
96
|
+
|
|
97
|
+
const linkRows: HealthRow[] = [];
|
|
98
|
+
if (orphans.length > 0) {
|
|
99
|
+
linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
|
|
100
|
+
const shown = orphans.slice(0, HEALTH_LIST_CAP);
|
|
101
|
+
for (let i = 0; i < shown.length; i++) {
|
|
102
|
+
linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
|
|
103
|
+
}
|
|
104
|
+
if (orphans.length > HEALTH_LIST_CAP) {
|
|
105
|
+
linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
|
|
109
|
+
}
|
|
110
|
+
if (dangling.length > 0) {
|
|
111
|
+
linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
|
|
112
|
+
const shown = dangling.slice(0, HEALTH_LIST_CAP);
|
|
113
|
+
for (let i = 0; i < shown.length; i++) {
|
|
114
|
+
linkRows.push({
|
|
115
|
+
id: `health:link:dangling:${shown[i]!.node.id}`,
|
|
116
|
+
text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
|
|
117
|
+
target: shown[i]!.node.id,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (dangling.length > HEALTH_LIST_CAP) {
|
|
121
|
+
linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (hubs.length > 0) {
|
|
125
|
+
linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
|
|
126
|
+
for (let i = 0; i < hubs.length; i++) {
|
|
127
|
+
linkRows.push({
|
|
128
|
+
id: `health:link:hub:${hubs[i]!.n.id}`,
|
|
129
|
+
text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
|
|
130
|
+
target: hubs[i]!.n.id,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
sections.push({ heading: "Link health", rows: linkRows });
|
|
135
|
+
|
|
136
|
+
return { sections };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ProvenanceCounts {
|
|
140
|
+
total: number;
|
|
141
|
+
human: number;
|
|
142
|
+
agent: number;
|
|
143
|
+
generated: number;
|
|
144
|
+
structural: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
|
|
148
|
+
const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
|
|
149
|
+
for (const n of nodes) {
|
|
150
|
+
if (n.provenance === "human") c.human++;
|
|
151
|
+
else if (n.provenance === "agent") c.agent++;
|
|
152
|
+
else if (n.provenance === "generated") c.generated++;
|
|
153
|
+
else c.structural++;
|
|
154
|
+
}
|
|
155
|
+
return c;
|
|
156
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/view — portable, renderer-agnostic view-models (weave-workspace §3).
|
|
3
|
+
*
|
|
4
|
+
* One implementation of the tree, detail, focus, and health projections, shared
|
|
5
|
+
* by the pi TUI and the browser workspace so they can never drift. Pure
|
|
6
|
+
* functions over `GraphModel`; no harness, no DOM, no terminal concepts.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./types";
|
|
9
|
+
export * from "./time";
|
|
10
|
+
export * from "./tree";
|
|
11
|
+
export * from "./detail";
|
|
12
|
+
export * from "./focus";
|
|
13
|
+
export * from "./links";
|
|
14
|
+
export * from "./health";
|
|
15
|
+
export * from "./cluster";
|