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,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
|
+
}
|