pi-weave 0.3.9 → 0.3.10
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 +5 -2
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +392 -153
- package/src/web/client/graph/Graph.tsx +143 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/Shell.tsx +61 -19
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +11 -1
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +252 -47
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph spotlight: light up the nodes a query names (UI refresh, G1).
|
|
3
|
+
*
|
|
4
|
+
* Type a word into the field floating over the graph and the nodes it names
|
|
5
|
+
* stay at full strength, grow and carry their labels while everything else
|
|
6
|
+
* drops back to faint dots; the lines *between* lit nodes light up too. A side
|
|
7
|
+
* sheet explains the node you are looking at, including why it lit.
|
|
8
|
+
*
|
|
9
|
+
* Everything the feature decides lives here, for §10's reason: the column
|
|
10
|
+
* component is a canvas plus a few overlays, and a branch inside a `.tsx` is a
|
|
11
|
+
* branch no test can reach. What this module answers:
|
|
12
|
+
*
|
|
13
|
+
* - {@link spotlightOf} — which nodes a query lights, and why each one did.
|
|
14
|
+
* - {@link litOnCanvas} — the same set in terms of what is *drawn*, so a lit
|
|
15
|
+
* note inside a collapsed folder lights the folder standing in for it.
|
|
16
|
+
* - {@link graphSheet} — the side sheet's content for the previewed node.
|
|
17
|
+
* - {@link spotlightEscapes} — whether `Esc` clears the query or falls through
|
|
18
|
+
* to the shell's existing "clear selection".
|
|
19
|
+
*
|
|
20
|
+
* The rendering itself is not here: the lit set is handed to the renderer's
|
|
21
|
+
* ordinary `setHighlight` with the `spotlight` style (`graph.model.ts`), the
|
|
22
|
+
* same path and the same fade a selection's neighbourhood takes.
|
|
23
|
+
*
|
|
24
|
+
* ## Tier rules (§2)
|
|
25
|
+
*
|
|
26
|
+
* `src/web/client/**`: wire types from `../../shared/wire`, core view-models
|
|
27
|
+
* only through `../../shared/view`. No DOM type is named, so the root
|
|
28
|
+
* `tsconfig.json` compiles the tests.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { relTime } from "../../shared/view";
|
|
32
|
+
import type { GraphPayload, WireGraphEdge, WireGraphNode, WireNoteSource } from "../../shared/wire";
|
|
33
|
+
import { excerptOf, stripMarkdown } from "../note/note.model";
|
|
34
|
+
import type { IconName } from "../shell/icons.model";
|
|
35
|
+
import { provenanceGlyph, provenanceTitle } from "../tree/tree.model";
|
|
36
|
+
import { graphPreview } from "./column.model";
|
|
37
|
+
|
|
38
|
+
// --- copy ------------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** The field's placeholder: what the spotlight can find, in the order people look. */
|
|
41
|
+
export const SPOTLIGHT_PLACEHOLDER = "Light up notes, tags or code";
|
|
42
|
+
|
|
43
|
+
/** The field's accessible name. */
|
|
44
|
+
export const SPOTLIGHT_LABEL = "Light up in graph";
|
|
45
|
+
|
|
46
|
+
/** The clear control's label and hint — it is also what `Esc` does. */
|
|
47
|
+
export const SPOTLIGHT_CLEAR = "Esc";
|
|
48
|
+
export const SPOTLIGHT_CLEAR_HINT = "Clear the spotlight (Esc)";
|
|
49
|
+
|
|
50
|
+
/** The legend while a spotlight is up, replacing the neighbourhood's. */
|
|
51
|
+
export const SPOTLIGHT_LEGEND = { lit: "lit", dimmed: "not lit" } as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why a node lit when only the search palette found it: its words are in the
|
|
55
|
+
* note's text, which the graph payload does not carry.
|
|
56
|
+
*/
|
|
57
|
+
export const SEARCH_REASON = "search match";
|
|
58
|
+
|
|
59
|
+
/** The count chip: `5 lit`, or a plain sentence when nothing matched. */
|
|
60
|
+
export function litLabel(count: number): string {
|
|
61
|
+
return count === 0 ? "no matches" : `${count} lit`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- the query -----------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** One word of a query. */
|
|
67
|
+
export interface SpotlightTerm {
|
|
68
|
+
/** Lower-cased, never empty. */
|
|
69
|
+
readonly text: string;
|
|
70
|
+
/** Written as `#word`: matches tags and nothing else. */
|
|
71
|
+
readonly tagOnly: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Split a query into terms.
|
|
76
|
+
*
|
|
77
|
+
* Words, not a phrase: `launch draft` lights "Launch post draft", which a
|
|
78
|
+
* substring match on the whole query would miss. A leading `#` makes a term
|
|
79
|
+
* tag-only, the way tags are written everywhere else in the workspace; a bare
|
|
80
|
+
* `#` names nothing and is dropped.
|
|
81
|
+
*/
|
|
82
|
+
export function spotlightTerms(query: string): SpotlightTerm[] {
|
|
83
|
+
const terms: SpotlightTerm[] = [];
|
|
84
|
+
for (const word of query.toLowerCase().split(/\s+/)) {
|
|
85
|
+
const tagOnly = word.startsWith("#");
|
|
86
|
+
const text = tagOnly ? word.replace(/^#+/, "") : word;
|
|
87
|
+
if (text !== "") terms.push({ text, tagOnly });
|
|
88
|
+
}
|
|
89
|
+
return terms;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- matching -------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** A node's matching path: the slug or repo path its id is derived from. */
|
|
95
|
+
export function nodePath(node: WireGraphNode): string {
|
|
96
|
+
const fromDetail = node.detail.path ?? node.detail.slug;
|
|
97
|
+
if (typeof fromDetail === "string" && fromDetail !== "") return fromDetail;
|
|
98
|
+
const colon = node.id.indexOf(":");
|
|
99
|
+
return colon === -1 ? "" : node.id.slice(colon + 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Note node id → its tags, from the payload's structured tag index (never `detail.tags`). */
|
|
103
|
+
export function tagsByNode(payload: GraphPayload): Map<string, string[]> {
|
|
104
|
+
const out = new Map<string, string[]>();
|
|
105
|
+
for (const [tag, slugs] of Object.entries(payload.tags)) {
|
|
106
|
+
for (const slug of slugs) {
|
|
107
|
+
const id = `note:${slug}`;
|
|
108
|
+
const list = out.get(id);
|
|
109
|
+
if (list === undefined) out.set(id, [tag]);
|
|
110
|
+
else if (!list.includes(tag)) list.push(tag);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The reasons one node matched, or `null` when some term found nothing. */
|
|
117
|
+
export function matchReasons(node: WireGraphNode, tags: readonly string[], terms: readonly SpotlightTerm[]): string[] | null {
|
|
118
|
+
const title = node.label.toLowerCase();
|
|
119
|
+
const path = nodePath(node).toLowerCase();
|
|
120
|
+
let byTitle = false;
|
|
121
|
+
let byPath = false;
|
|
122
|
+
const byTag: string[] = [];
|
|
123
|
+
for (const term of terms) {
|
|
124
|
+
const inTitle = !term.tagOnly && title.includes(term.text);
|
|
125
|
+
const tagHits = tags.filter((tag) => tag.toLowerCase().includes(term.text));
|
|
126
|
+
// The path is the weakest reason — a note's slug usually spells its own
|
|
127
|
+
// title — so it is only *named* when nothing better explains the term.
|
|
128
|
+
const inPath = !term.tagOnly && !inTitle && tagHits.length === 0 && path.includes(term.text);
|
|
129
|
+
if (!inTitle && tagHits.length === 0 && !inPath) return null;
|
|
130
|
+
byTitle ||= inTitle;
|
|
131
|
+
byPath ||= inPath;
|
|
132
|
+
for (const tag of tagHits) if (!byTag.includes(tag)) byTag.push(tag);
|
|
133
|
+
}
|
|
134
|
+
// Stable reading order — title, then tags in the index's order, then path —
|
|
135
|
+
// whatever order the words were typed in.
|
|
136
|
+
const ordered = tags.filter((tag) => byTag.includes(tag));
|
|
137
|
+
return [...(byTitle ? ["title"] : []), ...ordered.map((tag) => `tag #${tag}`), ...(byPath ? ["path"] : [])];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a query lit. `null` from {@link spotlightOf} means "no spotlight at all". */
|
|
141
|
+
export interface Spotlight {
|
|
142
|
+
readonly terms: readonly SpotlightTerm[];
|
|
143
|
+
/** Lit node ids, from the full payload — collapsed nodes included. */
|
|
144
|
+
readonly lit: ReadonlySet<string>;
|
|
145
|
+
/** Lit node id → why it lit: `title`, `tag #x`, `path`, `search match`. */
|
|
146
|
+
readonly reasons: ReadonlyMap<string, readonly string[]>;
|
|
147
|
+
/** How many nodes lit. The chip's number. */
|
|
148
|
+
readonly count: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Light up the nodes a query names.
|
|
153
|
+
*
|
|
154
|
+
* Case-insensitive, word by word: a node lights when **every** term is found
|
|
155
|
+
* in its title, one of its tags or its path. Matching runs over the whole
|
|
156
|
+
* payload rather than the drawn graph, so the count is the same whatever is
|
|
157
|
+
* expanded — {@link litOnCanvas} maps the answer onto the canvas.
|
|
158
|
+
*
|
|
159
|
+
* `found` is what the search palette listed for this same query, when the
|
|
160
|
+
* spotlight came from its "Light up in graph". Search reads note text the
|
|
161
|
+
* payload does not have, so a note it found by a word in its body lights too
|
|
162
|
+
* ({@link SEARCH_REASON}), and the graph never says "no matches" for results
|
|
163
|
+
* the palette just showed. Ids not in the payload are skipped.
|
|
164
|
+
*
|
|
165
|
+
* `null` for an empty (or whitespace, or bare `#`) query: no spotlight, which
|
|
166
|
+
* the column renders as the ordinary graph. A query that matches nothing is
|
|
167
|
+
* *not* `null` — it is an empty set, and the canvas dims entirely, which is
|
|
168
|
+
* the honest picture of "nothing here is called that".
|
|
169
|
+
*/
|
|
170
|
+
export function spotlightOf(payload: GraphPayload | null, query: string, found: readonly string[] = []): Spotlight | null {
|
|
171
|
+
const terms = spotlightTerms(query);
|
|
172
|
+
if (terms.length === 0) return null;
|
|
173
|
+
const lit = new Set<string>();
|
|
174
|
+
const reasons = new Map<string, readonly string[]>();
|
|
175
|
+
if (payload !== null) {
|
|
176
|
+
const tags = tagsByNode(payload);
|
|
177
|
+
for (const node of payload.model.nodes) {
|
|
178
|
+
if (lit.has(node.id)) continue;
|
|
179
|
+
const why = matchReasons(node, tags.get(node.id) ?? [], terms);
|
|
180
|
+
if (why === null) continue;
|
|
181
|
+
lit.add(node.id);
|
|
182
|
+
reasons.set(node.id, why);
|
|
183
|
+
}
|
|
184
|
+
const searched = new Set(found);
|
|
185
|
+
for (const node of payload.model.nodes) {
|
|
186
|
+
if (lit.has(node.id) || !searched.has(node.id)) continue;
|
|
187
|
+
lit.add(node.id);
|
|
188
|
+
reasons.set(node.id, [SEARCH_REASON]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { terms, lit, reasons, count: lit.size };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The lit set in terms of what is drawn.
|
|
196
|
+
*
|
|
197
|
+
* A lit node hidden inside a collapsed cluster is not on the canvas, so the
|
|
198
|
+
* cluster representing it lights instead — the same retargeting the
|
|
199
|
+
* neighbourhood highlight relies on (`clusterAggregate`'s `representative`).
|
|
200
|
+
* `null` in, `null` out.
|
|
201
|
+
*/
|
|
202
|
+
export function litOnCanvas(spot: Spotlight | null, representative: ReadonlyMap<string, string>): Set<string> | null {
|
|
203
|
+
if (spot === null) return null;
|
|
204
|
+
const out = new Set<string>();
|
|
205
|
+
for (const id of spot.lit) out.add(representative.get(id) ?? id);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `title, tag #launch` — the sheet's "Lit because" line — or `null` for an unlit node. */
|
|
210
|
+
export function litBecause(spot: Spotlight | null, id: string): string | null {
|
|
211
|
+
const why = spot?.reasons.get(id);
|
|
212
|
+
return why === undefined || why.length === 0 ? null : why.join(", ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `Esc` clears the query.
|
|
217
|
+
*
|
|
218
|
+
* Inside the field, a query is what `Esc` clears — the habit of every search
|
|
219
|
+
* box. Elsewhere in the graph it clears the query only when no sheet is open:
|
|
220
|
+
* an open sheet is the nearer thing, and closing it is the shell's existing
|
|
221
|
+
* `Esc` (clear the selection). With no query there is nothing to clear here,
|
|
222
|
+
* so `Esc` keeps exactly the behaviour it had before the spotlight existed.
|
|
223
|
+
*/
|
|
224
|
+
export function spotlightEscapes(query: string, inField: boolean, sheetOpen: boolean): boolean {
|
|
225
|
+
return query !== "" && (inField || !sheetOpen);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- marking the words ------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** A run of text, marked when it is one of the query's words. */
|
|
231
|
+
export interface MarkedPart {
|
|
232
|
+
readonly text: string;
|
|
233
|
+
readonly hit: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Split `text` into runs, marking every occurrence of a term.
|
|
238
|
+
*
|
|
239
|
+
* Case-insensitive, left to right, earliest match first and the longest term
|
|
240
|
+
* at a tie, never overlapping — so `launch` inside "Launches" marks `Launch`
|
|
241
|
+
* and leaves `es` plain. Tag-only terms mark too: the sheet's text is where
|
|
242
|
+
* the reader looks for the word they typed, whatever field it matched.
|
|
243
|
+
*/
|
|
244
|
+
export function markParts(text: string, terms: readonly SpotlightTerm[]): MarkedPart[] {
|
|
245
|
+
const words = [...new Set(terms.map((term) => term.text))].sort((a, b) => b.length - a.length);
|
|
246
|
+
const lower = text.toLowerCase();
|
|
247
|
+
const parts: MarkedPart[] = [];
|
|
248
|
+
let at = 0;
|
|
249
|
+
let plain = "";
|
|
250
|
+
while (at < text.length) {
|
|
251
|
+
const word = words.find((candidate) => lower.startsWith(candidate, at));
|
|
252
|
+
if (word === undefined) {
|
|
253
|
+
plain += text[at];
|
|
254
|
+
at += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (plain !== "") parts.push({ text: plain, hit: false });
|
|
258
|
+
plain = "";
|
|
259
|
+
parts.push({ text: text.slice(at, at + word.length), hit: true });
|
|
260
|
+
at += word.length;
|
|
261
|
+
}
|
|
262
|
+
if (plain !== "") parts.push({ text: plain, hit: false });
|
|
263
|
+
return parts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- the side sheet --------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/** The sheet's text block's heading. */
|
|
269
|
+
export type SheetTextKind = "TLDR" | "Summary" | "Preview";
|
|
270
|
+
|
|
271
|
+
/** The sheet's text block: a note's TLDR, a summary, or the note's opening words. */
|
|
272
|
+
export interface SheetText {
|
|
273
|
+
readonly heading: SheetTextKind;
|
|
274
|
+
readonly parts: readonly MarkedPart[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One row of the sheet's Connected list. */
|
|
278
|
+
export interface SheetRow {
|
|
279
|
+
readonly id: string;
|
|
280
|
+
readonly label: string;
|
|
281
|
+
readonly lit: boolean;
|
|
282
|
+
/** The row's accessible name: the dot says "lit" to the eye, this says it aloud. */
|
|
283
|
+
readonly ariaLabel: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Who wrote a note, as the sheet says it. */
|
|
287
|
+
export interface SheetWho {
|
|
288
|
+
readonly provenance: WireNoteSource;
|
|
289
|
+
readonly glyph: string;
|
|
290
|
+
readonly word: string;
|
|
291
|
+
readonly title: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Everything the side sheet renders for one node. */
|
|
295
|
+
export interface GraphSheet {
|
|
296
|
+
readonly id: string;
|
|
297
|
+
readonly title: string;
|
|
298
|
+
readonly kind: string;
|
|
299
|
+
readonly icon: IconName;
|
|
300
|
+
/** Where it lives: `projects`, `projects / 2026`, `Vault`. Empty for a root. */
|
|
301
|
+
readonly crumb: string;
|
|
302
|
+
readonly who: SheetWho | null;
|
|
303
|
+
/** `2h ago`, or `""` when the node carries no time. */
|
|
304
|
+
readonly when: string;
|
|
305
|
+
readonly text: SheetText | null;
|
|
306
|
+
/** The "Lit because" line, or `null` when the node did not light. */
|
|
307
|
+
readonly litBecause: string | null;
|
|
308
|
+
readonly connected: readonly SheetRow[];
|
|
309
|
+
/** All connections, of which {@link connected} shows the first few. */
|
|
310
|
+
readonly connectedTotal: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How many connections the sheet lists before it stops. */
|
|
314
|
+
export const CONNECTED_LIMIT = 6;
|
|
315
|
+
|
|
316
|
+
/** The sheet's section heading and its overflow line. */
|
|
317
|
+
export const CONNECTED_HEADING = "Connected";
|
|
318
|
+
export function connectedMore(shown: number, total: number): string | null {
|
|
319
|
+
return total > shown ? `+${total - shown} more` : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The sheet's labels. */
|
|
323
|
+
export const SHEET_LABEL = "Graph node details";
|
|
324
|
+
export const SHEET_CLOSE = "Close details";
|
|
325
|
+
export const SHEET_OPEN = "Open";
|
|
326
|
+
export const SHEET_OPEN_SPLIT = "Open in split";
|
|
327
|
+
export const LIT_BECAUSE = "Lit because:";
|
|
328
|
+
|
|
329
|
+
/** Provenance → the word the sheet's meta line uses. The replay's vocabulary. */
|
|
330
|
+
const WHO_WORDS: Readonly<Record<WireNoteSource, string>> = {
|
|
331
|
+
human: "You",
|
|
332
|
+
agent: "Agent",
|
|
333
|
+
generated: "Generated",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** The edge kinds that are hierarchy rather than content. */
|
|
337
|
+
const STRUCTURAL = new Set<WireGraphEdge["kind"]>(["contains", "anchored-at"]);
|
|
338
|
+
|
|
339
|
+
/** The containment parent of every node, first edge wins (as `clusterAggregate` attributes). */
|
|
340
|
+
function parentsOf(edges: readonly WireGraphEdge[]): Map<string, string> {
|
|
341
|
+
const out = new Map<string, string>();
|
|
342
|
+
for (const edge of edges) {
|
|
343
|
+
if (STRUCTURAL.has(edge.kind) && !out.has(edge.target) && edge.source !== edge.target) out.set(edge.target, edge.source);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The deepest crumb segments shown before an ellipsis takes over. */
|
|
349
|
+
export const CRUMB_DEPTH = 3;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Where a node lives, as a breadcrumb of its containment ancestors.
|
|
353
|
+
*
|
|
354
|
+
* The root itself is left off when there is anything below it — "projects"
|
|
355
|
+
* says where a note is, "Vault / projects" says it twice — and kept when it is
|
|
356
|
+
* the only ancestor, because a root note's crumb is the root. Deep chains keep
|
|
357
|
+
* their last {@link CRUMB_DEPTH} folders behind an ellipsis. Cycle-safe.
|
|
358
|
+
*/
|
|
359
|
+
export function crumbOf(payload: GraphPayload, id: string): string {
|
|
360
|
+
const parents = parentsOf(payload.model.edges);
|
|
361
|
+
const labels = new Map(payload.model.nodes.map((node) => [node.id, node.label] as const));
|
|
362
|
+
const chain: string[] = [];
|
|
363
|
+
const seen = new Set<string>([id]);
|
|
364
|
+
let at = parents.get(id);
|
|
365
|
+
while (at !== undefined && !seen.has(at)) {
|
|
366
|
+
seen.add(at);
|
|
367
|
+
chain.unshift(labels.get(at) ?? at);
|
|
368
|
+
at = parents.get(at);
|
|
369
|
+
}
|
|
370
|
+
const below = chain.length > 1 ? chain.slice(1) : chain;
|
|
371
|
+
const shown = below.length > CRUMB_DEPTH ? ["…", ...below.slice(-CRUMB_DEPTH)] : below;
|
|
372
|
+
return shown.join(" / ");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The TLDR a finalized note opens with, from its preview.
|
|
377
|
+
*
|
|
378
|
+
* The notepad skill writes `**TLDR:**` directly under the title (skills/
|
|
379
|
+
* weave-notepad). The preview is the body's first characters with whitespace
|
|
380
|
+
* flattened, so the block ends at the next heading or the next bold label;
|
|
381
|
+
* checkbox and bullet markers that lost their line starts are turned back
|
|
382
|
+
* into readable separators. `null` when the note has no TLDR.
|
|
383
|
+
*/
|
|
384
|
+
export function tldrOf(preview: string): string | null {
|
|
385
|
+
const found = /\*\*TL;?DR:?\*\*:?|(?:^|\s)TL;?DR:/i.exec(preview);
|
|
386
|
+
if (found === null) return null;
|
|
387
|
+
let rest = preview.slice(found.index + found[0].length);
|
|
388
|
+
const stop = rest.search(/(?:^|\s)#{1,6}\s|\*\*[^*]+:\*\*/);
|
|
389
|
+
if (stop >= 0) rest = rest.slice(0, stop);
|
|
390
|
+
const text = stripMarkdown(
|
|
391
|
+
rest
|
|
392
|
+
.replace(/(?:^|\s)[-*+]\s+\[[xX]\]\s*/g, " ☑ ")
|
|
393
|
+
.replace(/(?:^|\s)[-*+]\s+\[ \]\s*/g, " ☐ ")
|
|
394
|
+
.replace(/(?:^|\s)[-*+]\s+/g, " · "),
|
|
395
|
+
)
|
|
396
|
+
.replace(/\s+/g, " ")
|
|
397
|
+
.replace(/^\s*·\s*/, "")
|
|
398
|
+
.trim();
|
|
399
|
+
return text === "" ? null : text;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** How long the sheet's text block may run. */
|
|
403
|
+
export const SHEET_TEXT_LIMIT = 200;
|
|
404
|
+
|
|
405
|
+
/** The sheet's text block for a node, or `null` when the payload has none. */
|
|
406
|
+
export function sheetText(node: WireGraphNode, terms: readonly SpotlightTerm[]): SheetText | null {
|
|
407
|
+
const preview = node.detail.preview ?? "";
|
|
408
|
+
const tldr = tldrOf(preview);
|
|
409
|
+
if (tldr !== null) return { heading: "TLDR", parts: markParts(excerptOf(tldr, SHEET_TEXT_LIMIT), terms) };
|
|
410
|
+
const summary = node.detail.summary ?? node.detail.description ?? "";
|
|
411
|
+
if (summary.trim() !== "") return { heading: "Summary", parts: markParts(excerptOf(summary, SHEET_TEXT_LIMIT), terms) };
|
|
412
|
+
const opening = excerptOf(preview, SHEET_TEXT_LIMIT);
|
|
413
|
+
return opening === "" ? null : { heading: "Preview", parts: markParts(opening, terms) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The node's connections, as the sheet lists them.
|
|
418
|
+
*
|
|
419
|
+
* Content edges first — links and mentions, in either direction — because
|
|
420
|
+
* "connected" means what the node is *about*, not which folder it sits in.
|
|
421
|
+
* A node with no content edges (a folder, a module) lists its children
|
|
422
|
+
* instead. Lit connections come first, then alphabetical.
|
|
423
|
+
*/
|
|
424
|
+
export function connectionsOf(payload: GraphPayload, id: string, spot: Spotlight | null): SheetRow[] {
|
|
425
|
+
const byId = new Map(payload.model.nodes.map((node) => [node.id, node] as const));
|
|
426
|
+
const collect = (keep: (edge: WireGraphEdge) => string | null): string[] => {
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
for (const edge of payload.model.edges) {
|
|
429
|
+
const other = keep(edge);
|
|
430
|
+
if (other !== null && other !== id && byId.has(other) && !out.includes(other)) out.push(other);
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
};
|
|
434
|
+
const content = collect((edge) => (STRUCTURAL.has(edge.kind) ? null : edge.source === id ? edge.target : edge.target === id ? edge.source : null));
|
|
435
|
+
const ids = content.length > 0 ? content : collect((edge) => (STRUCTURAL.has(edge.kind) && edge.source === id ? edge.target : null));
|
|
436
|
+
const rows = ids.map((other) => {
|
|
437
|
+
const node = byId.get(other) as WireGraphNode;
|
|
438
|
+
const lit = spot?.lit.has(other) ?? false;
|
|
439
|
+
return { id: other, label: node.label, lit, ariaLabel: lit ? `${node.label} (lit)` : node.label };
|
|
440
|
+
});
|
|
441
|
+
return rows.sort((a, b) => Number(b.lit) - Number(a.lit) || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The side sheet for the previewed node, or `null` when there is none.
|
|
446
|
+
*
|
|
447
|
+
* Built from the graph payload alone — no extra request: the node's label,
|
|
448
|
+
* kind and `detail` (provenance, `updated`, the preview the TLDR is read
|
|
449
|
+
* from, a deep-scan summary), its containment parents for the crumb and its
|
|
450
|
+
* edges for the Connected list. `now` is injected, per AGENTS.md.
|
|
451
|
+
*/
|
|
452
|
+
export function graphSheet(payload: GraphPayload | null, id: string | null, spot: Spotlight | null, now: number): GraphSheet | null {
|
|
453
|
+
const preview = graphPreview(payload, id);
|
|
454
|
+
if (preview === null || payload === null) return null;
|
|
455
|
+
const node = payload.model.nodes.find((candidate) => candidate.id === preview.id) as WireGraphNode;
|
|
456
|
+
const connected = connectionsOf(payload, preview.id, spot);
|
|
457
|
+
return {
|
|
458
|
+
id: preview.id,
|
|
459
|
+
title: preview.title,
|
|
460
|
+
kind: preview.kind,
|
|
461
|
+
icon: preview.icon,
|
|
462
|
+
crumb: crumbOf(payload, preview.id),
|
|
463
|
+
who: node.provenance === null
|
|
464
|
+
? null
|
|
465
|
+
: { provenance: node.provenance, glyph: provenanceGlyph(node.provenance), word: WHO_WORDS[node.provenance], title: provenanceTitle(node.provenance) },
|
|
466
|
+
when: relTime(node.detail.updated, now),
|
|
467
|
+
text: sheetText(node, spot?.terms ?? []),
|
|
468
|
+
litBecause: litBecause(spot, preview.id),
|
|
469
|
+
connected: connected.slice(0, CONNECTED_LIMIT),
|
|
470
|
+
connectedTotal: connected.length,
|
|
471
|
+
};
|
|
472
|
+
}
|