pi-weave 0.1.11 → 0.1.13
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 +148 -132
- package/package.json +1 -2
- package/src/core/cache/workspace.ts +26 -9
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +12 -8
- package/src/core/graph/current.ts +4 -6
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/slug.ts +13 -0
- package/src/core/types.ts +1 -0
- package/src/core/vault.ts +45 -467
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +15 -3
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -176
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +77 -167
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +37 -60
- package/src/web/client/note/note.model.ts +23 -0
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -81
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -123
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -121
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +24 -97
- package/src/web/client/tree/tree.model.ts +8 -56
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -415
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -178
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/src/web/client/api.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One function per route, each returning a discriminated {@link ApiResult}
|
|
5
5
|
* rather than throwing. That is the whole design decision here, and it is
|
|
6
|
-
* driven by §6: the client refetches on every
|
|
7
|
-
*
|
|
6
|
+
* driven by §6: the client refetches on every poll and on every explicit
|
|
7
|
+
* refresh, so a failed request is a *normal, frequent* occurrence — the
|
|
8
8
|
* server may be mid-restart, the session may have been killed, the token may
|
|
9
9
|
* have rotated. Exceptions are for the unexpected, and none of these are.
|
|
10
10
|
* A caller that must handle "the server went away" on every call is better
|
|
@@ -36,17 +36,7 @@
|
|
|
36
36
|
* fortnight.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import type {
|
|
40
|
-
ConflictPayload,
|
|
41
|
-
DeleteNoteResult,
|
|
42
|
-
GraphPayload,
|
|
43
|
-
NotePayload,
|
|
44
|
-
OkfFilePayload,
|
|
45
|
-
OpenResult,
|
|
46
|
-
SaveNoteRequest,
|
|
47
|
-
SearchPayload,
|
|
48
|
-
ViewNote,
|
|
49
|
-
} from "../shared/wire";
|
|
39
|
+
import type { GraphPayload, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
|
|
50
40
|
|
|
51
41
|
// --- the injected HTTP port ------------------------------------------------------
|
|
52
42
|
|
|
@@ -70,6 +60,7 @@ export interface HttpRequest {
|
|
|
70
60
|
readonly method?: string;
|
|
71
61
|
readonly headers?: Record<string, string>;
|
|
72
62
|
readonly body?: string;
|
|
63
|
+
readonly signal?: { readonly aborted: boolean };
|
|
73
64
|
}
|
|
74
65
|
|
|
75
66
|
/** The injected `fetch`. The platform's satisfies this structurally. */
|
|
@@ -105,29 +96,6 @@ export interface ApiFailure {
|
|
|
105
96
|
readonly message: string;
|
|
106
97
|
}
|
|
107
98
|
|
|
108
|
-
/**
|
|
109
|
-
* A `409` from a write route — its own arm, not a sixth {@link ApiErrorKind}.
|
|
110
|
-
*
|
|
111
|
-
* Every other failure is a dead end: the caller shows a message and stops. A
|
|
112
|
-
* conflict is the opposite — it is the server handing back **the information
|
|
113
|
-
* needed to continue**, and the editor's whole reload-or-overwrite prompt is
|
|
114
|
-
* built from `conflict.current`. Folding it into `ApiFailure` would mean
|
|
115
|
-
* either an optional field every caller has to remember is only sometimes
|
|
116
|
-
* there, or a cast; a separate arm makes the payload's presence a fact the
|
|
117
|
-
* compiler enforces once the caller has narrowed on `kind`.
|
|
118
|
-
*
|
|
119
|
-
* The `409` is therefore *not* an error in the sense the other five are, and
|
|
120
|
-
* the type says so.
|
|
121
|
-
*/
|
|
122
|
-
export interface ApiConflict {
|
|
123
|
-
readonly ok: false;
|
|
124
|
-
readonly kind: "conflict";
|
|
125
|
-
readonly status: 409;
|
|
126
|
-
readonly message: string;
|
|
127
|
-
/** The server's `409` body: the current note, or the taken slug. */
|
|
128
|
-
readonly conflict: ConflictPayload;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
99
|
export interface ApiSuccess<T> {
|
|
132
100
|
readonly ok: true;
|
|
133
101
|
readonly data: T;
|
|
@@ -140,9 +108,6 @@ export interface ApiSuccess<T> {
|
|
|
140
108
|
|
|
141
109
|
export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
|
|
142
110
|
|
|
143
|
-
/** The result of a write: as {@link ApiResult}, plus the `409` arm. */
|
|
144
|
-
export type WriteResult<T> = ApiSuccess<T> | ApiFailure | ApiConflict;
|
|
145
|
-
|
|
146
111
|
function success<T>(data: T, cached = false): ApiSuccess<T> {
|
|
147
112
|
return { ok: true, data, cached };
|
|
148
113
|
}
|
|
@@ -226,7 +191,7 @@ function isStringArray(value: unknown): value is string[] {
|
|
|
226
191
|
*
|
|
227
192
|
* `model.nodes` and `model.edges` are verified to be arrays and `stamp` to be
|
|
228
193
|
* a string, because those three are what the columns index into and what the
|
|
229
|
-
*
|
|
194
|
+
* conditional request compares. Individual node fields are not walked: a per-node
|
|
230
195
|
* validation pass on every refetch is real cost on a large graph, and a
|
|
231
196
|
* malformed node degrades one row rather than crashing the app. The cutoff is
|
|
232
197
|
* "what breaks the shell if absent", not "everything the type declares".
|
|
@@ -249,35 +214,9 @@ export function isViewNote(value: unknown): value is ViewNote {
|
|
|
249
214
|
return isStringArray(value["tags"]);
|
|
250
215
|
}
|
|
251
216
|
|
|
252
|
-
/** `NotePayload` —
|
|
217
|
+
/** `NotePayload` — the note response wrapper. */
|
|
253
218
|
export function isNotePayload(value: unknown): value is NotePayload {
|
|
254
|
-
return isObject(value) &&
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** `DeleteNoteResult`. */
|
|
258
|
-
export function isDeleteResult(value: unknown): value is DeleteNoteResult {
|
|
259
|
-
return isObject(value) && value["deleted"] === true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* `ConflictPayload` — the `409` body.
|
|
264
|
-
*
|
|
265
|
-
* Checked to the depth the prompt renders, which for a `conflict` is the
|
|
266
|
-
* whole nested note: the reload button writes `current.note` into the column
|
|
267
|
-
* and the overwrite button sends `current.revision`, so a payload missing
|
|
268
|
-
* either would produce a dialog whose buttons do nothing. Falling back to a
|
|
269
|
-
* generic "server error" is the honest outcome for a malformed one.
|
|
270
|
-
*/
|
|
271
|
-
export function isConflictPayload(value: unknown): value is ConflictPayload {
|
|
272
|
-
if (!isObject(value) || typeof value["error"] !== "string") return false;
|
|
273
|
-
if (value["reason"] === "collision") return typeof value["slug"] === "string";
|
|
274
|
-
if (value["reason"] !== "conflict") return false;
|
|
275
|
-
return isNotePayload(value["current"]);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** `OkfFilePayload`. */
|
|
279
|
-
export function isOkfFile(value: unknown): value is OkfFilePayload {
|
|
280
|
-
return isObject(value) && typeof value["path"] === "string" && typeof value["body"] === "string";
|
|
219
|
+
return isObject(value) && isViewNote(value["note"]);
|
|
281
220
|
}
|
|
282
221
|
|
|
283
222
|
/** `SearchPayload`. Hits are checked as an array; ranking tolerates junk. */
|
|
@@ -336,127 +275,26 @@ export async function fetchGraph(fetchImpl: FetchLike, previous: GraphPayload |
|
|
|
336
275
|
* `GET /api/note/:slug`.
|
|
337
276
|
*
|
|
338
277
|
* The slug is percent-encoded here and traversal-checked *there* — the
|
|
339
|
-
* server hands it to `resolveNotePath`, which rejects
|
|
340
|
-
*
|
|
278
|
+
* server hands it to `resolveNotePath`, which rejects unsafe slugs. Doing our
|
|
279
|
+
* own check as well would be a second implementation to
|
|
341
280
|
* keep in sync, which is how traversal bugs are actually born (see
|
|
342
281
|
* `routes.ts`). Encoding is still required, because an unencoded `#` or `?`
|
|
343
282
|
* would silently truncate the path.
|
|
344
283
|
*
|
|
345
|
-
* Returns a {@link NotePayload}, not a bare `ViewNote
|
|
346
|
-
*
|
|
347
|
-
* window in which the two describe different states of the file, and a save
|
|
348
|
-
* carrying a revision that does not match the body it was typed against is
|
|
349
|
-
* worse than a save carrying none.
|
|
284
|
+
* Returns a {@link NotePayload}, not a bare `ViewNote`, so the response shape
|
|
285
|
+
* stays explicit at the HTTP boundary.
|
|
350
286
|
*/
|
|
351
287
|
export function fetchNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<NotePayload>> {
|
|
352
288
|
return request(fetchImpl, `/api/note/${encodeURIComponent(slug)}`, isNotePayload);
|
|
353
289
|
}
|
|
354
290
|
|
|
355
|
-
/** The URL for one note. One definition, so the four routes cannot disagree. */
|
|
356
|
-
function noteUrl(slug: string, suffix = ""): string {
|
|
357
|
-
return `/api/note/${encodeURIComponent(slug)}${suffix}`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** A JSON write, as {@link HttpRequest}. */
|
|
361
|
-
function writeInit(method: string, body: unknown): HttpRequest {
|
|
362
|
-
return { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Perform a write, decoding the `409` rather than flattening it.
|
|
367
|
-
*
|
|
368
|
-
* The generic {@link request} cannot serve here: it maps every non-2xx to an
|
|
369
|
-
* {@link ApiFailure} with a message and no body, and the `409` body is the
|
|
370
|
-
* entire point — it carries the note the user must choose between keeping
|
|
371
|
-
* and discarding. So the conflict is intercepted before the status
|
|
372
|
-
* classification and decoded through {@link isConflictPayload}.
|
|
373
|
-
*
|
|
374
|
-
* A `409` whose body does *not* decode falls through to a `server` failure.
|
|
375
|
-
* That is deliberate rather than defensive: presenting a reload-or-overwrite
|
|
376
|
-
* prompt built from a payload we could not read would offer the user two
|
|
377
|
-
* buttons, at least one of which silently does nothing.
|
|
378
|
-
*/
|
|
379
|
-
async function write<T>(
|
|
380
|
-
fetchImpl: FetchLike,
|
|
381
|
-
url: string,
|
|
382
|
-
guard: (value: unknown) => value is T,
|
|
383
|
-
init: HttpRequest,
|
|
384
|
-
): Promise<WriteResult<T>> {
|
|
385
|
-
let response: HttpResponse;
|
|
386
|
-
try {
|
|
387
|
-
response = await fetchImpl(url, init);
|
|
388
|
-
} catch (error) {
|
|
389
|
-
return failure("network", 0, error instanceof Error ? error.message : "network request failed");
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (response.status === 409) {
|
|
393
|
-
let body: unknown;
|
|
394
|
-
try {
|
|
395
|
-
body = await response.json();
|
|
396
|
-
} catch {
|
|
397
|
-
return failure("malformed", 409, "response was not valid JSON");
|
|
398
|
-
}
|
|
399
|
-
if (!isConflictPayload(body)) return failure("malformed", 409, "response did not match the expected shape");
|
|
400
|
-
return { ok: false, kind: "conflict", status: 409, message: body.error, conflict: body };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (!response.ok) return failure(classifyStatus(response.status), response.status, messageForStatus(response.status));
|
|
404
|
-
|
|
405
|
-
let body: unknown;
|
|
406
|
-
try {
|
|
407
|
-
body = await response.json();
|
|
408
|
-
} catch {
|
|
409
|
-
return failure("malformed", response.status, "response was not valid JSON");
|
|
410
|
-
}
|
|
411
|
-
if (!guard(body)) return failure("malformed", response.status, "response did not match the expected shape");
|
|
412
|
-
return success(body);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* `POST /api/note/:slug` — save (§11 P5.3, P5.5).
|
|
417
|
-
*
|
|
418
|
-
* `input.expectedRevision` is what makes a save safe, and omitting it is what
|
|
419
|
-
* makes one an overwrite. Both are legitimate and neither is a default the
|
|
420
|
-
* caller should stumble into, so this function takes the request verbatim
|
|
421
|
-
* rather than deciding for it: `editor.model.ts` supplies the revision on a
|
|
422
|
-
* normal save and drops it only after the user has looked at a conflict and
|
|
423
|
-
* chosen to win.
|
|
424
|
-
*/
|
|
425
|
-
export function saveNote(fetchImpl: FetchLike, slug: string, input: SaveNoteRequest): Promise<WriteResult<NotePayload>> {
|
|
426
|
-
return write(fetchImpl, noteUrl(slug), isNotePayload, writeInit("POST", input));
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* `POST /api/note/:slug/rename`.
|
|
431
|
-
*
|
|
432
|
-
* A `409` here is a `collision`, not a `conflict` — the destination is
|
|
433
|
-
* taken. The server refuses rather than uniquifying, because landing
|
|
434
|
-
* somewhere other than where the user asked hides their mistake.
|
|
435
|
-
*/
|
|
436
|
-
export function renameNote(fetchImpl: FetchLike, slug: string, target: string): Promise<WriteResult<NotePayload>> {
|
|
437
|
-
return write(fetchImpl, noteUrl(slug, "/rename"), isNotePayload, writeInit("POST", { slug: target }));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** `DELETE /api/note/:slug`. Hard delete — the vault has no trash. */
|
|
441
|
-
export function deleteNote(fetchImpl: FetchLike, slug: string): Promise<WriteResult<DeleteNoteResult>> {
|
|
442
|
-
return write(fetchImpl, noteUrl(slug), isDeleteResult, { method: "DELETE" });
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** `GET /api/okf/:rel`. Anchored under `<cwd>/.okf` by the server. */
|
|
446
|
-
export function fetchOkfFile(fetchImpl: FetchLike, rel: string): Promise<ApiResult<OkfFilePayload>> {
|
|
447
|
-
// Per segment: `encodeURIComponent` would escape the separators of a
|
|
448
|
-
// relative path like `index/notes.md` into `%2F` and produce a 404.
|
|
449
|
-
const encoded = rel.split("/").map(encodeURIComponent).join("/");
|
|
450
|
-
return request(fetchImpl, `/api/okf/${encoded}`, isOkfFile);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
291
|
/** `GET /api/search?q=`. An empty query is valid and returns no hits. */
|
|
454
|
-
export function fetchSearch(fetchImpl: FetchLike, query: string): Promise<ApiResult<SearchPayload>> {
|
|
455
|
-
return request(fetchImpl, `/api/search?q=${encodeURIComponent(query)}`, isSearchPayload);
|
|
292
|
+
export function fetchSearch(fetchImpl: FetchLike, query: string, signal?: { readonly aborted: boolean }): Promise<ApiResult<SearchPayload>> {
|
|
293
|
+
return request(fetchImpl, `/api/search?q=${encodeURIComponent(query)}`, isSearchPayload, signal === undefined ? undefined : { signal });
|
|
456
294
|
}
|
|
457
295
|
|
|
458
296
|
/**
|
|
459
|
-
* `POST /api/open` — the
|
|
297
|
+
* `POST /api/open` — hand the selected note to the user's editor.
|
|
460
298
|
*
|
|
461
299
|
* A missing note answers `404` with `{opened:false}`, which surfaces here as
|
|
462
300
|
* a `missing` failure rather than a success carrying `false`. The status code
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reading the page's bootstrap block
|
|
2
|
+
* Reading the page's bootstrap block.
|
|
3
3
|
*
|
|
4
|
-
* `page.ts` embeds `{cwd
|
|
4
|
+
* `page.ts` embeds `{cwd}` as a nonce'd
|
|
5
5
|
* `<script type="application/json">` so the first paint knows where it is
|
|
6
6
|
* without a round trip. This decodes it.
|
|
7
7
|
*
|
|
@@ -21,24 +21,15 @@
|
|
|
21
21
|
import type { Bootstrap } from "../shared/wire";
|
|
22
22
|
|
|
23
23
|
/** What the client assumes when the page told it nothing. */
|
|
24
|
-
export const EMPTY_BOOTSTRAP: Bootstrap = { cwd: ""
|
|
24
|
+
export const EMPTY_BOOTSTRAP: Bootstrap = { cwd: "" };
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Structural guard
|
|
28
|
-
*
|
|
29
|
-
* `session` is unused at P1 and checked anyway: it is what a later phase uses
|
|
30
|
-
* to tell "I missed frames" from "this is a different server" (see its
|
|
31
|
-
* `wire.ts` comment), and a guard that admits a bootstrap without it would
|
|
32
|
-
* let that distinction fail silently the day it starts mattering.
|
|
27
|
+
* Structural guard for the one value the client reads.
|
|
33
28
|
*/
|
|
34
29
|
function isBootstrap(value: unknown): value is Bootstrap {
|
|
35
30
|
if (typeof value !== "object" || value === null) return false;
|
|
36
31
|
const candidate = value as Record<string, unknown>;
|
|
37
|
-
return
|
|
38
|
-
typeof candidate["cwd"] === "string" &&
|
|
39
|
-
typeof candidate["vaultRoot"] === "string" &&
|
|
40
|
-
typeof candidate["session"] === "string"
|
|
41
|
-
);
|
|
32
|
+
return typeof candidate["cwd"] === "string";
|
|
42
33
|
}
|
|
43
34
|
|
|
44
35
|
/**
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import type { DetailLinkRow, ViewGraphModel } from "../../shared/view";
|
|
40
40
|
import { deriveBacklinks, detailModel, listLabel } from "../../shared/view";
|
|
41
41
|
import type { GraphPayload, WireGraphNode, WireNodeKind, WireNoteSource } from "../../shared/wire";
|
|
42
|
-
import {
|
|
42
|
+
import { kindIcon, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
|
|
43
43
|
import type { IconName } from "../shell/icons.model";
|
|
44
44
|
|
|
45
45
|
// --- rows and groups ---------------------------------------------------------------
|
|
@@ -83,7 +83,7 @@ export interface ContextModel {
|
|
|
83
83
|
* A tag has a name, a count and a list of sibling notes; flattening that
|
|
84
84
|
* into the same array as the link rows would mean a union type and a
|
|
85
85
|
* discriminant check inside the component — which is a branch, in a `.tsx`,
|
|
86
|
-
* where
|
|
86
|
+
* where says branches cannot be covered. Two fields is the cheaper
|
|
87
87
|
* shape.
|
|
88
88
|
*/
|
|
89
89
|
readonly tags: readonly TagGroupRow[];
|
|
@@ -108,10 +108,8 @@ export const HEADINGS = { links: "LINKS", backlinks: "BACKLINKS", tags: "TAGS",
|
|
|
108
108
|
/**
|
|
109
109
|
* A row from a node. The rail's one place that reads a node's presentation.
|
|
110
110
|
*
|
|
111
|
-
* The icon follows the tree's rule,
|
|
112
|
-
*
|
|
113
|
-
* tree's icon is how "this is the same thing" is communicated across the two
|
|
114
|
-
* columns.
|
|
111
|
+
* The icon follows the tree's rule, so the same node is represented the same
|
|
112
|
+
* way in both columns.
|
|
115
113
|
*/
|
|
116
114
|
export function rowFor(id: string, node: WireGraphNode, selectedId: string | null): ContextRow {
|
|
117
115
|
return {
|
|
@@ -119,7 +117,7 @@ export function rowFor(id: string, node: WireGraphNode, selectedId: string | nul
|
|
|
119
117
|
target: node.id,
|
|
120
118
|
label: listLabel(node),
|
|
121
119
|
kind: node.kind,
|
|
122
|
-
kindIcon:
|
|
120
|
+
kindIcon: kindIcon(node.kind),
|
|
123
121
|
provenance: node.provenance,
|
|
124
122
|
provenanceGlyph: provenanceGlyph(node.provenance),
|
|
125
123
|
provenanceTitle: provenanceTitle(node.provenance),
|
|
@@ -186,7 +184,7 @@ function isMentionRow(row: DetailLinkRow): boolean {
|
|
|
186
184
|
*
|
|
187
185
|
* From `GraphPayload.tags` (§4.3) — the structured `tag → slugs` index — and
|
|
188
186
|
* never from `WireGraphNode.detail.tags`, which is a comma-joined display
|
|
189
|
-
* string that §4.
|
|
187
|
+
* string that §4.3 forbids turning back into structure. The membership test is
|
|
190
188
|
* therefore "does this tag's slug list contain mine", which is exact even for
|
|
191
189
|
* a tag whose name contains a comma.
|
|
192
190
|
*
|
|
@@ -277,7 +275,7 @@ function emptyRail(reason: string): ContextModel {
|
|
|
277
275
|
* Build the rail.
|
|
278
276
|
*
|
|
279
277
|
* Empty groups are **omitted**, not rendered as headings over nothing. The
|
|
280
|
-
* rail sits under the graph column in a fixed-height region (§1.
|
|
278
|
+
* rail sits under the graph column in a fixed-height region (§1.1), so four
|
|
281
279
|
* headings with one row between them wastes the space that the one populated
|
|
282
280
|
* group needed — and a heading with nothing under it reads as a load that
|
|
283
281
|
* failed rather than as an absence.
|
|
@@ -332,7 +330,7 @@ export function contextModel(payload: GraphPayload | null, selectedId: string |
|
|
|
332
330
|
return { subject: detail.label, groups, tags, tagsCount: tags.length, empty: null };
|
|
333
331
|
}
|
|
334
332
|
|
|
335
|
-
// --- counts and collapse (Tier 6,
|
|
333
|
+
// --- counts and collapse (Tier 6, P6.4) ---------------------------------------------
|
|
336
334
|
|
|
337
335
|
/**
|
|
338
336
|
* A rail section as the component renders it: the heading's count, whether it
|
|
@@ -374,7 +372,7 @@ export function emptyRailToggles(): RailToggles {
|
|
|
374
372
|
* A section longer than this opens collapsed.
|
|
375
373
|
*
|
|
376
374
|
* Eight is where a list stops being scannable without scrolling: the rail has
|
|
377
|
-
* a fixed fraction of a fixed column (§1.
|
|
375
|
+
* a fixed fraction of a fixed column (§1.1), so a MENTIONS section with forty
|
|
378
376
|
* entries would push the sections below it out of the viewport entirely —
|
|
379
377
|
* which is how a rail meant to show *everything at once* ends up showing one
|
|
380
378
|
* group. Under eight the rhythm would cost more than it saved.
|