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.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The context rail: everything related, visible without navigating
3
+ * (weave-workspace §1.1, §1.2, §10, P2.5).
4
+ *
5
+ * §1.1's UX principle is the one that decides arguments here:
6
+ *
7
+ * > **Don't make the user navigate to information. Bring related information
8
+ * > into the current view.**
9
+ *
10
+ * The rail is that principle made concrete. Whatever is selected, the four
11
+ * groups below say what it points at, what points at it, what it is filed
12
+ * under, and what code it talks about — all at a glance, with no click that
13
+ * costs you your place. §1.2 sketches it as `LINKS` / `BACKLINKS`, and §11's
14
+ * P2 line adds mentions and tags now that §4.3 and §4.4 have shipped the data.
15
+ *
16
+ * ## Where each group comes from
17
+ *
18
+ * | Group | Source | Why not something else |
19
+ * | --- | --- | --- |
20
+ * | LINKS | `detailModel().links`, minus mentions | Core's projection, shared with the TUI (§3). |
21
+ * | BACKLINKS | `deriveBacklinks()` over all edges | One pass for the whole graph, not O(nodes × edges) per selection. |
22
+ * | TAGS | `GraphPayload.tags` (§4.3) | The structured index. Never `detail.tags`, which is a display string. |
23
+ * | MENTIONS | `mentions` edges (§4.4) | Both directions: what a note names, and which notes name a file. |
24
+ *
25
+ * `detailModel` is used rather than `focusModel` even though `focusModel`
26
+ * groups the neighbourhood already: its grouping is by *edge kind* with the
27
+ * headings a terminal wants ("links to →", "← linked from"), and the rail
28
+ * needs tags and mentions interleaved on equal footing with those. Reaching
29
+ * through `detailModel` and regrouping keeps core's link/backlink derivation
30
+ * shared while letting the browser decide its own section order.
31
+ *
32
+ * ## Tier rules (§2)
33
+ *
34
+ * `src/web/client/**`. View-models via `../../shared/view`; never `src/core`.
35
+ * No DOM type is named, so the root `tsconfig.json` project compiles the
36
+ * tests.
37
+ */
38
+
39
+ import type { DetailLinkRow, ViewGraphModel } from "../../shared/view";
40
+ import { deriveBacklinks, detailModel, listLabel } from "../../shared/view";
41
+ import type { GraphPayload, WireGraphNode, WireNodeKind, WireNoteSource } from "../../shared/wire";
42
+ import { kindGlyph, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
43
+
44
+ // --- rows and groups ---------------------------------------------------------------
45
+
46
+ /** One clickable entry in the rail. */
47
+ export interface ContextRow {
48
+ /** Unique within the rail, for a stable render key. */
49
+ readonly id: string;
50
+ /** The node id to select. Every row is clickable — that is the point. */
51
+ readonly target: string;
52
+ readonly label: string;
53
+ readonly kind: WireNodeKind;
54
+ readonly kindGlyph: string;
55
+ readonly provenance: WireNoteSource | null;
56
+ readonly provenanceGlyph: string;
57
+ readonly provenanceTitle: string;
58
+ /** Highlighted when this row is what is currently selected. */
59
+ readonly selected: boolean;
60
+ }
61
+
62
+ /** One labelled section. */
63
+ export interface ContextGroup {
64
+ /** `LINKS`, `BACKLINKS`, `TAGS`, `MENTIONS`. */
65
+ readonly heading: string;
66
+ /** Shown when the group is present but empty. Never rendered when hidden. */
67
+ readonly rows: readonly ContextRow[];
68
+ }
69
+
70
+ /** The whole rail for one selection. */
71
+ export interface ContextModel {
72
+ /** The selected node's own label, for the rail's subtitle. */
73
+ readonly subject: string | null;
74
+ /** LINKS, BACKLINKS and MENTIONS. Empty groups are omitted, never rendered. */
75
+ readonly groups: readonly ContextGroup[];
76
+ /**
77
+ * TAGS, carried separately because a tag row is not a {@link ContextRow}.
78
+ *
79
+ * A tag has a name, a count and a list of sibling notes; flattening that
80
+ * into the same array as the link rows would mean a union type and a
81
+ * discriminant check inside the component — which is a branch, in a `.tsx`,
82
+ * where §10 says branches cannot be covered. Two fields is the cheaper
83
+ * shape.
84
+ */
85
+ readonly tags: readonly TagGroupRow[];
86
+ /** Present instead of groups when there is nothing to show. */
87
+ readonly empty: string | null;
88
+ }
89
+
90
+ /** Section headings, in the order §1.2 sketches them. */
91
+ export const HEADINGS = { links: "LINKS", backlinks: "BACKLINKS", tags: "TAGS", mentions: "MENTIONS" } as const;
92
+
93
+ // --- building rows ------------------------------------------------------------------
94
+
95
+ /** A row from a node. The rail's one place that reads a node's presentation. */
96
+ export function rowFor(id: string, node: WireGraphNode, selectedId: string | null): ContextRow {
97
+ return {
98
+ id,
99
+ target: node.id,
100
+ label: listLabel(node),
101
+ kind: node.kind,
102
+ kindGlyph: kindGlyph(node.kind),
103
+ provenance: node.provenance,
104
+ provenanceGlyph: provenanceGlyph(node.provenance),
105
+ provenanceTitle: provenanceTitle(node.provenance),
106
+ selected: node.id === selectedId,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * A row from one of core's `DetailLinkRow`s.
112
+ *
113
+ * `DetailLinkRow.label` is pre-composed for a terminal — `"links-to → Alpha"`,
114
+ * `"← Alpha"` — and the rail already says which direction a section is in its
115
+ * heading, so repeating it in every row is noise. The node is looked up and
116
+ * relabelled instead, which also recovers the kind glyph and provenance mark
117
+ * that the terminal's single label string had flattened away.
118
+ *
119
+ * Exported so its two `null` paths are directly testable. Both are guards
120
+ * against a shape core does not currently produce — `SelectableRow.target` is
121
+ * optional in general but always set on a `DetailLinkRow`, and core filters
122
+ * unresolvable targets before returning — so reaching them through
123
+ * {@link contextModel} would mean constructing a payload core cannot emit.
124
+ * A guard that cannot be exercised is a guard nobody can check is right.
125
+ */
126
+ export function rowFromDetail(row: DetailLinkRow, byId: ReadonlyMap<string, WireGraphNode>, selectedId: string | null): ContextRow | null {
127
+ const target = row.target;
128
+ if (target === undefined) return null;
129
+ const node = byId.get(target);
130
+ // The guard for a truncated payload, where an edge can outlive the node it
131
+ // points at.
132
+ return node === undefined ? null : rowFor(row.id, node, selectedId);
133
+ }
134
+
135
+ /** Index a payload's nodes by id. */
136
+ function nodesById(model: ViewGraphModel): Map<string, WireGraphNode> {
137
+ const byId = new Map<string, WireGraphNode>();
138
+ for (const node of model.nodes) byId.set(node.id, node);
139
+ return byId;
140
+ }
141
+
142
+ // --- the four groups ------------------------------------------------------------------
143
+
144
+ /**
145
+ * Outgoing edges, excluding `mentions` and structural containment.
146
+ *
147
+ * `contains` and `anchored-at` are deliberately dropped: they are the tree's
148
+ * job, the tree is on screen at the same time, and a `repository` node's rail
149
+ * would otherwise be a second copy of the tree with every module in it.
150
+ * `mentions` is dropped here because it gets a section of its own.
151
+ */
152
+ const LINK_EDGE_PREFIXES = ["link:links-to:"];
153
+
154
+ /** True when a `DetailLinkRow`'s id names an edge kind the LINKS group shows. */
155
+ function isLinkRow(row: DetailLinkRow): boolean {
156
+ return LINK_EDGE_PREFIXES.some((prefix) => row.id.startsWith(prefix));
157
+ }
158
+
159
+ /** True when a `DetailLinkRow` names a `mentions` edge. */
160
+ function isMentionRow(row: DetailLinkRow): boolean {
161
+ return row.id.startsWith("link:mentions:");
162
+ }
163
+
164
+ /**
165
+ * Tag rows for a note.
166
+ *
167
+ * From `GraphPayload.tags` (§4.3) — the structured `tag → slugs` index — and
168
+ * never from `WireGraphNode.detail.tags`, which is a comma-joined display
169
+ * string that §4.2 forbids turning back into structure. The membership test is
170
+ * therefore "does this tag's slug list contain mine", which is exact even for
171
+ * a tag whose name contains a comma.
172
+ *
173
+ * The rows target the tag's *other* notes rather than the tag itself, because
174
+ * there is no tag node in the graph to select. A tag with only this note in it
175
+ * still renders — it says something true about the note — but contributes no
176
+ * navigable siblings, so its count reads `1`.
177
+ */
178
+ export interface TagGroupRow {
179
+ readonly tag: string;
180
+ /** Notes carrying this tag, this one included. */
181
+ readonly count: number;
182
+ /** The other notes, clickable. */
183
+ readonly siblings: readonly ContextRow[];
184
+ }
185
+
186
+ /** Tags for a slug, with the notes that share them. */
187
+ export function tagsFor(payload: GraphPayload, slug: string, byId: ReadonlyMap<string, WireGraphNode>, selectedId: string | null): TagGroupRow[] {
188
+ const out: TagGroupRow[] = [];
189
+ // Key order is `deriveTagIndex`'s: count descending, then tag ascending
190
+ // (§4.3), and `Object.entries` preserves it. So the popular tags surface
191
+ // first with no sorting here — re-sorting would discard a ranking the server
192
+ // already computed and the ETag already depends on.
193
+ for (const [tag, slugs] of Object.entries(payload.tags)) {
194
+ if (!slugs.includes(slug)) continue;
195
+ const siblings: ContextRow[] = [];
196
+ for (const other of slugs) {
197
+ if (other === slug) continue;
198
+ const node = byId.get(`note:${other}`);
199
+ if (node !== undefined) siblings.push(rowFor(`tag:${tag}:${other}`, node, selectedId));
200
+ }
201
+ out.push({ tag, count: slugs.length, siblings });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /**
207
+ * Incoming `mentions`: which notes name this file or module (§4.4).
208
+ *
209
+ * The complement of the outgoing half, and the more useful direction of the
210
+ * two — standing on `src/core/vault.ts` and seeing which notes discuss it is
211
+ * the question a repository workspace exists to answer. `deriveBacklinks` is
212
+ * no help here: it filters to `links-to` by design, so mentions need their own
213
+ * pass.
214
+ */
215
+ export function incomingMentions(model: ViewGraphModel, id: string, byId: ReadonlyMap<string, WireGraphNode>, selectedId: string | null): ContextRow[] {
216
+ const rows: ContextRow[] = [];
217
+ for (const edge of model.edges) {
218
+ if (edge.kind !== "mentions" || edge.target !== id) continue;
219
+ const node = byId.get(edge.source);
220
+ if (node !== undefined) rows.push(rowFor(`mentioned-by:${edge.source}`, node, selectedId));
221
+ }
222
+ return rows;
223
+ }
224
+
225
+ // --- the model ----------------------------------------------------------------------------
226
+
227
+ /**
228
+ * The slug inside a `note:<slug>` id, or `null`.
229
+ *
230
+ * Mirrors `workspace.ts`' `noteSlug` and `note.model.ts`' `slugOfNode`. Three
231
+ * copies of a five-line rule is one too many, and the right fix is for the
232
+ * node-id format to live in one module the client tier shares — but that is a
233
+ * refactor across three files that P2 is not the commit for. Exported so the
234
+ * empty-slug case is testable here rather than only through a payload that
235
+ * would need a malformed node in it.
236
+ */
237
+ export function slugOf(id: string): string | null {
238
+ if (!id.startsWith("note:")) return null;
239
+ const slug = id.slice("note:".length);
240
+ return slug === "" ? null : slug;
241
+ }
242
+
243
+ /** Copy for the rail's own empty states. */
244
+ export const RAIL_EMPTY = {
245
+ loading: "Loading…",
246
+ noSelection: "Select anything to see what it connects to.",
247
+ unknown: "This node is not in the current graph.",
248
+ isolated: "Nothing links to or from this yet.",
249
+ } as const;
250
+
251
+ /** The rail with nothing to show, and a reason. */
252
+ function emptyRail(reason: string): ContextModel {
253
+ return { subject: null, groups: [], tags: [], empty: reason };
254
+ }
255
+
256
+ /**
257
+ * Build the rail.
258
+ *
259
+ * Empty groups are **omitted**, not rendered as headings over nothing. The
260
+ * rail sits under the graph column in a fixed-height region (§1.2), so four
261
+ * headings with one row between them wastes the space that the one populated
262
+ * group needed — and a heading with nothing under it reads as a load that
263
+ * failed rather than as an absence.
264
+ *
265
+ * `now` is not a parameter: unlike the tree and the note header, nothing in
266
+ * the rail is time-relative. Rows are labels and glyphs, which is the point —
267
+ * a rail that changed as the clock moved would re-render three columns for no
268
+ * reason.
269
+ */
270
+ export function contextModel(payload: GraphPayload | null, selectedId: string | null): ContextModel {
271
+ if (payload === null) return emptyRail(RAIL_EMPTY.loading);
272
+ if (selectedId === null) return emptyRail(RAIL_EMPTY.noSelection);
273
+
274
+ const model = viewModel(payload);
275
+ const detail = detailModel(model, selectedId);
276
+ if (detail === null) return emptyRail(RAIL_EMPTY.unknown);
277
+
278
+ const byId = nodesById(model);
279
+ const groups: ContextGroup[] = [];
280
+ const push = (heading: string, rows: readonly ContextRow[]): void => {
281
+ if (rows.length > 0) groups.push({ heading, rows });
282
+ };
283
+
284
+ const resolve = (rows: readonly DetailLinkRow[]): ContextRow[] =>
285
+ rows.map((row) => rowFromDetail(row, byId, selectedId)).filter((row): row is ContextRow => row !== null);
286
+
287
+ push(HEADINGS.links, resolve(detail.links.filter(isLinkRow)));
288
+
289
+ // From the whole-graph pass rather than `detail.backlinks`, which is the
290
+ // same data — but this map is built once and the rail is rebuilt on every
291
+ // selection, so the shared pass is the one that scales.
292
+ const backlinks = deriveBacklinks(model.edges).get(selectedId) ?? [];
293
+ push(
294
+ HEADINGS.backlinks,
295
+ backlinks
296
+ .map((source) => byId.get(source))
297
+ .filter((node): node is WireGraphNode => node !== undefined)
298
+ .map((node) => rowFor(`backlink:${node.id}`, node, selectedId)),
299
+ );
300
+
301
+ // Both directions in one section: what this names, and what names it. A
302
+ // note sees the code it discusses; a module sees the notes discussing it,
303
+ // and no node is ever both in practice.
304
+ push(HEADINGS.mentions, [...resolve(detail.links.filter(isMentionRow)), ...incomingMentions(model, selectedId, byId, selectedId)]);
305
+
306
+ const slug = slugOf(selectedId);
307
+ const tags = slug === null ? [] : tagsFor(payload, slug, byId, selectedId);
308
+
309
+ if (groups.length === 0 && tags.length === 0) {
310
+ return { subject: detail.label, groups: [], tags: [], empty: RAIL_EMPTY.isolated };
311
+ }
312
+ return { subject: detail.label, groups, tags, empty: null };
313
+ }