pi-weave 0.1.7 → 0.1.9
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 +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- 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/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- 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 +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -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 +339 -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/selection.storage.ts +69 -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 +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -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 +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -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 +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -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,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the tree column *decides* (weave-workspace §1.2, §3, §10).
|
|
3
|
+
*
|
|
4
|
+
* The column itself is `treeRows` with a different renderer — that is §3's
|
|
5
|
+
* whole claim, and this module is what makes it true in the browser: it holds
|
|
6
|
+
* the view state the TUI keeps in `ExplorerState`, the reducers that move it,
|
|
7
|
+
* and the presentation mapping from a `TreeRow` to something a `<li>` can
|
|
8
|
+
* render. `Tree.tsx` is left with a `useState`, a `map` and four handlers.
|
|
9
|
+
*
|
|
10
|
+
* That split is not stylistic. §10 forbids a DOM test environment, so a branch
|
|
11
|
+
* inside a `.tsx` is a branch no test can reach; every branch the tree needs
|
|
12
|
+
* lives here, where an ordinary unit test covers it.
|
|
13
|
+
*
|
|
14
|
+
* ## Tier rules (§2)
|
|
15
|
+
*
|
|
16
|
+
* `src/web/client/**`: `src/web/shared` and browser deps only. The view-models
|
|
17
|
+
* arrive through `../../shared/view`, the one sanctioned door onto
|
|
18
|
+
* `src/core/view` (§2.1) — never from `src/core` directly, even though the
|
|
19
|
+
* modules behind the door are proven node-free. This file also touches no DOM
|
|
20
|
+
* type at all, which is what lets the root `tsconfig.json` project (no `DOM`
|
|
21
|
+
* lib) compile the tests that import it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { TreeRow, TreeState, ViewGraphModel } from "../../shared/view";
|
|
25
|
+
import { formatTreeMeta, treeEmptyHint, treeRows } from "../../shared/view";
|
|
26
|
+
import type { GraphPayload, WireNodeKind, WireNoteSource } from "../../shared/wire";
|
|
27
|
+
|
|
28
|
+
// --- the view state ------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The tree's own state — core's {@link TreeState}, verbatim.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately an alias rather than a parallel interface. `treeRows` reads
|
|
34
|
+
* `expanded`, `showInternals`, `provFilter` and `query`; a client-side copy
|
|
35
|
+
* with the same four fields would be a second declaration of one contract, and
|
|
36
|
+
* the first time core added a fifth the browser would silently stop honouring
|
|
37
|
+
* it.
|
|
38
|
+
*
|
|
39
|
+
* It is **not** `src/web/client/state.ts`'s `TreeState`. That one is a P1
|
|
40
|
+
* placeholder carrying only an expanded-id list, and it stays where it is:
|
|
41
|
+
* this state is owned by the tree column, lives in the component that renders
|
|
42
|
+
* it, and never crosses the context bus. §1.3's bus is `selectedId` — which
|
|
43
|
+
* rows happen to be open is not something the note column or the graph has any
|
|
44
|
+
* business reacting to.
|
|
45
|
+
*/
|
|
46
|
+
export type TreeViewState = TreeState;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The provenance filter cycle: all → human → agent → generated → all.
|
|
50
|
+
*
|
|
51
|
+
* The same order as the TUI's `p` key (`src/pi/viewer/tui/theme.ts`), and
|
|
52
|
+
* declared again here because the client tier may not import `src/pi` and the
|
|
53
|
+
* order is not part of `src/core/view`. That is a real, if small, duplication
|
|
54
|
+
* — so it is one array with a test asserting its contents, rather than an
|
|
55
|
+
* `if/else` chain in two places. If a fourth provenance is ever added, the
|
|
56
|
+
* honest fix is to promote this into `src/core/view` and take it through the
|
|
57
|
+
* door; today it is four literals and a promotion would be ceremony.
|
|
58
|
+
*/
|
|
59
|
+
export const PROVENANCE_CYCLE: readonly (WireNoteSource | null)[] = [null, "human", "agent", "generated"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tree as it opens: nothing expanded but the roots, no filter.
|
|
63
|
+
*
|
|
64
|
+
* Roots are expanded eagerly because a tree whose every row is collapsed shows
|
|
65
|
+
* two words (`vault`, `repository`) and reads as an empty column. The TUI's
|
|
66
|
+
* `initialState` does the same thing for the same reason.
|
|
67
|
+
*
|
|
68
|
+
* A fresh `Set` per call, so two callers cannot alias — and, more importantly,
|
|
69
|
+
* so a reducer that mutated one could not corrupt the default for the next
|
|
70
|
+
* mount.
|
|
71
|
+
*/
|
|
72
|
+
export function initialTreeView(roots: readonly string[] = ["vault", "repository"]): TreeViewState {
|
|
73
|
+
return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- reassembling the wire payload -------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The wire payload as the view-models want it.
|
|
80
|
+
*
|
|
81
|
+
* `WireGraphModel` is `Omit<GraphModel, "danglingLinks">` — the payload hoists
|
|
82
|
+
* that map to its own top-level `dangling` rather than shipping it twice
|
|
83
|
+
* (§4.2) — so exactly one field has to be put back before `treeRows` or
|
|
84
|
+
* `detailModel` will accept it. The door (`shared/view.ts`) deliberately does
|
|
85
|
+
* not do this: it is a wire concern, and a door that carried a transformation
|
|
86
|
+
* would be a second implementation rather than a re-export.
|
|
87
|
+
*
|
|
88
|
+
* So it is done here, once, and every caller in the client goes through it.
|
|
89
|
+
*/
|
|
90
|
+
export function viewModel(payload: GraphPayload): ViewGraphModel {
|
|
91
|
+
return { ...payload.model, danglingLinks: payload.dangling };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The rows to render, or `[]` before the first graph arrives.
|
|
96
|
+
*
|
|
97
|
+
* `null` is not an error state — it is the half-second between mount and the
|
|
98
|
+
* first `/api/graph` response, and it happens on every single load. Returning
|
|
99
|
+
* an empty array rather than throwing keeps the caller a `map`.
|
|
100
|
+
*/
|
|
101
|
+
export function rowsFor(payload: GraphPayload | null, state: TreeViewState): TreeRow[] {
|
|
102
|
+
if (payload === null) return [];
|
|
103
|
+
return treeRows(viewModel(payload), state);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- reducers -----------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Open or close a row.
|
|
110
|
+
*
|
|
111
|
+
* Returns a **new** state with a new `Set`. Mutating the existing one would be
|
|
112
|
+
* cheaper and would not re-render: Preact's `useState` bails out on
|
|
113
|
+
* `Object.is` equality, so an in-place `expanded.add(id)` produces a correct
|
|
114
|
+
* model and a frozen screen. That failure is silent and maddening, which is
|
|
115
|
+
* why every reducer here copies.
|
|
116
|
+
*/
|
|
117
|
+
export function toggleExpanded(state: TreeViewState, id: string): TreeViewState {
|
|
118
|
+
const expanded = new Set(state.expanded);
|
|
119
|
+
if (!expanded.delete(id)) expanded.add(id);
|
|
120
|
+
return { ...state, expanded };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Force a row open. Idempotent — the right-arrow key's half of the pair. */
|
|
124
|
+
export function expand(state: TreeViewState, id: string): TreeViewState {
|
|
125
|
+
if (state.expanded.has(id)) return state;
|
|
126
|
+
const expanded = new Set(state.expanded);
|
|
127
|
+
expanded.add(id);
|
|
128
|
+
return { ...state, expanded };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Force a row closed. Idempotent. */
|
|
132
|
+
export function collapse(state: TreeViewState, id: string): TreeViewState {
|
|
133
|
+
if (!state.expanded.has(id)) return state;
|
|
134
|
+
const expanded = new Set(state.expanded);
|
|
135
|
+
expanded.delete(id);
|
|
136
|
+
return { ...state, expanded };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Set the substring filter.
|
|
141
|
+
*
|
|
142
|
+
* No debounce and no minimum length: `treeRows` is a synchronous walk over an
|
|
143
|
+
* in-memory model of a few hundred nodes, so filtering on every keystroke is
|
|
144
|
+
* cheaper than the timer that would avoid it, and a filter that lags behind
|
|
145
|
+
* the text box is the single most irritating thing a filter can do.
|
|
146
|
+
*/
|
|
147
|
+
export function setQuery(state: TreeViewState, query: string): TreeViewState {
|
|
148
|
+
return state.query === query ? state : { ...state, query };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Advance the provenance filter one step around {@link PROVENANCE_CYCLE}. */
|
|
152
|
+
export function cycleProvenance(state: TreeViewState): TreeViewState {
|
|
153
|
+
const at = PROVENANCE_CYCLE.indexOf(state.provFilter);
|
|
154
|
+
// `indexOf` returns -1 for a value outside the cycle, and `(-1 + 1) % 4` is
|
|
155
|
+
// 0 — which lands on "all". A state that somehow held an unknown filter
|
|
156
|
+
// therefore recovers on the next press instead of sticking.
|
|
157
|
+
const next = PROVENANCE_CYCLE[(at + 1) % PROVENANCE_CYCLE.length] ?? null;
|
|
158
|
+
return { ...state, provFilter: next };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Show or hide repo plumbing (gitState / external / package / entryPoint). */
|
|
162
|
+
export function toggleInternals(state: TreeViewState): TreeViewState {
|
|
163
|
+
return { ...state, showInternals: !state.showInternals };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- keyboard navigation --------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/** What a key produced: the next state, and the row that should be selected. */
|
|
169
|
+
export interface TreeKeyResult {
|
|
170
|
+
readonly state: TreeViewState;
|
|
171
|
+
readonly selectedId: string | null;
|
|
172
|
+
/** `false` when the key meant nothing here and the browser should keep it. */
|
|
173
|
+
readonly handled: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The index of `id` among the currently visible rows, or `-1`.
|
|
178
|
+
*
|
|
179
|
+
* Rows are the flattened, filtered, expansion-resolved list — so "the next
|
|
180
|
+
* row" is genuinely the next thing on screen, not the next sibling in the
|
|
181
|
+
* graph. Deriving the cursor from the id rather than storing an index is the
|
|
182
|
+
* same choice the TUI made (`ExplorerState.selectedId` is the source of truth)
|
|
183
|
+
* and for the same reason: an index goes stale the moment a filter changes the
|
|
184
|
+
* row set, and goes stale *plausibly*, pointing at a real but wrong row.
|
|
185
|
+
*/
|
|
186
|
+
export function indexOfRow(rows: readonly TreeRow[], id: string | null): number {
|
|
187
|
+
if (id === null) return -1;
|
|
188
|
+
return rows.findIndex((row) => row.id === id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The id at a row index, or `null` when the index is outside the list.
|
|
193
|
+
*
|
|
194
|
+
* Exported and used by every caller below rather than each writing
|
|
195
|
+
* `rows[i]?.id ?? null` inline, and the reason is coverage rather than
|
|
196
|
+
* brevity. `noUncheckedIndexedAccess` makes an index access `T | undefined`
|
|
197
|
+
* even where the surrounding arithmetic has already clamped it into range, so
|
|
198
|
+
* an inline `?? null` is a branch that *cannot* be taken and therefore cannot
|
|
199
|
+
* be covered — a permanent hole in a gate that is supposed to mean something.
|
|
200
|
+
* Funnelling those accesses through one function turns the same check into a
|
|
201
|
+
* branch that is genuinely reachable, because an out-of-range index is a legal
|
|
202
|
+
* argument here and is tested as one.
|
|
203
|
+
*/
|
|
204
|
+
export function idAt(rows: readonly TreeRow[], index: number): string | null {
|
|
205
|
+
return rows[index]?.id ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Move the cursor `delta` rows, clamped to the ends.
|
|
210
|
+
*
|
|
211
|
+
* Clamped rather than wrapped: wrapping from the last note back to `vault` is
|
|
212
|
+
* disorienting in a tree, where position carries meaning. With nothing
|
|
213
|
+
* selected, a downward move starts at the top and an upward move at the
|
|
214
|
+
* bottom, so the first arrow key after a fresh load always lands somewhere.
|
|
215
|
+
*/
|
|
216
|
+
export function moveSelection(rows: readonly TreeRow[], id: string | null, delta: number): string | null {
|
|
217
|
+
if (rows.length === 0) return null;
|
|
218
|
+
const at = indexOfRow(rows, id);
|
|
219
|
+
const from = at === -1 ? (delta > 0 ? -1 : rows.length) : at;
|
|
220
|
+
return idAt(rows, Math.min(rows.length - 1, Math.max(0, from + delta)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The id of the row that visually contains `id` — the row above it with a
|
|
225
|
+
* smaller depth.
|
|
226
|
+
*
|
|
227
|
+
* Used by the left arrow on an already-collapsed row, which is the one tree
|
|
228
|
+
* gesture users expect and nobody implements. Scanning upwards for the first
|
|
229
|
+
* shallower row is exact for a flattened tree: whatever that row is, it is the
|
|
230
|
+
* parent, because every row between them is a descendant of it.
|
|
231
|
+
*
|
|
232
|
+
* Written as a reversed `slice` rather than a descending index loop for the
|
|
233
|
+
* reason {@link idAt} exists: iterating yields a `TreeRow`, where indexing
|
|
234
|
+
* yields `TreeRow | undefined` and forces an unreachable guard. The copy is of
|
|
235
|
+
* the rows above the cursor, which is bounded by what is on screen.
|
|
236
|
+
*/
|
|
237
|
+
export function parentOf(rows: readonly TreeRow[], id: string): string | null {
|
|
238
|
+
const at = indexOfRow(rows, id);
|
|
239
|
+
// `at <= 0` covers both "not visible" (-1) and "the first row, which has
|
|
240
|
+
// nothing above it" (0), and in either case there is no parent to find.
|
|
241
|
+
const self = at <= 0 ? undefined : rows[at];
|
|
242
|
+
if (self === undefined) return null;
|
|
243
|
+
for (const row of rows.slice(0, at).reverse()) {
|
|
244
|
+
if (row.depth < self.depth) return row.id;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Vim-ish aliases: `j` is `ArrowDown`, `k` is `ArrowUp` (§11 P4).
|
|
251
|
+
*
|
|
252
|
+
* A *normalizer* rather than two more branches in {@link treeKey}, because
|
|
253
|
+
* the aliasing and the navigation are separate concerns and folding them
|
|
254
|
+
* together would double the arrow tests. Everything below `treeKey`'s first
|
|
255
|
+
* line then works in terms of arrows only.
|
|
256
|
+
*
|
|
257
|
+
* ## Why the aliases are tree-scoped and not global
|
|
258
|
+
*
|
|
259
|
+
* §11 says "vim-ish `j/k` **in the tree**", and the qualifier is load-bearing
|
|
260
|
+
* on both sides. A global `j` would move the tree's cursor while the user is
|
|
261
|
+
* reading the note column — an invisible change to a surface they are not
|
|
262
|
+
* looking at — and it would make `j` untypeable in the graph's depth control.
|
|
263
|
+
* Scoping it here means the tree's own `onKeyDown` applies it, which is only
|
|
264
|
+
* reached when focus is genuinely inside the tree.
|
|
265
|
+
*
|
|
266
|
+
* Case is preserved deliberately: `J` and `K` are Shift-modified keys that
|
|
267
|
+
* vim itself binds to something else, so they are left alone rather than
|
|
268
|
+
* folded onto their lowercase forms.
|
|
269
|
+
*/
|
|
270
|
+
export const VIM_KEYS: Readonly<Record<string, string>> = { j: "ArrowDown", k: "ArrowUp" };
|
|
271
|
+
|
|
272
|
+
/** Resolve a vim alias, or return the key unchanged. */
|
|
273
|
+
export function normalizeTreeKey(key: string): string {
|
|
274
|
+
return VIM_KEYS[key] ?? key;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Apply a key to the tree.
|
|
279
|
+
*
|
|
280
|
+
* `ArrowDown`/`ArrowUp` move, `Home`/`End` jump, `ArrowRight` opens (or
|
|
281
|
+
* descends into an already-open row), `ArrowLeft` closes (or climbs to the
|
|
282
|
+
* parent). That last pair is the whole reason this is a function rather than a
|
|
283
|
+
* switch in the component: each arrow has two behaviours depending on the
|
|
284
|
+
* row's state, which is four branches the coverage gate would never see inside
|
|
285
|
+
* a `.tsx`.
|
|
286
|
+
*
|
|
287
|
+
* `handled: false` for everything else, so the caller knows not to
|
|
288
|
+
* `preventDefault` a key it did not consume — swallowing Tab would trap
|
|
289
|
+
* keyboard users in the column, which is the accessibility bug P4 is meant to
|
|
290
|
+
* be fixing rather than introducing.
|
|
291
|
+
*/
|
|
292
|
+
export function treeKey(
|
|
293
|
+
rows: readonly TreeRow[],
|
|
294
|
+
state: TreeViewState,
|
|
295
|
+
selectedId: string | null,
|
|
296
|
+
rawKey: string,
|
|
297
|
+
): TreeKeyResult {
|
|
298
|
+
const unchanged = { state, selectedId, handled: false } as const;
|
|
299
|
+
const moved = (id: string | null): TreeKeyResult => ({ state, selectedId: id, handled: true });
|
|
300
|
+
const key = normalizeTreeKey(rawKey);
|
|
301
|
+
|
|
302
|
+
if (key === "ArrowDown") return moved(moveSelection(rows, selectedId, 1));
|
|
303
|
+
if (key === "ArrowUp") return moved(moveSelection(rows, selectedId, -1));
|
|
304
|
+
if (key === "Home") return moved(idAt(rows, 0));
|
|
305
|
+
if (key === "End") return moved(idAt(rows, rows.length - 1));
|
|
306
|
+
|
|
307
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return unchanged;
|
|
308
|
+
|
|
309
|
+
const at = indexOfRow(rows, selectedId);
|
|
310
|
+
const row = at === -1 ? undefined : rows[at];
|
|
311
|
+
if (row === undefined || selectedId === null) return unchanged;
|
|
312
|
+
|
|
313
|
+
if (key === "ArrowRight") {
|
|
314
|
+
// Closed and has children → open it. Already open → step onto the first
|
|
315
|
+
// child, which is the next row by construction. A leaf does nothing.
|
|
316
|
+
if (!row.hasKids) return unchanged;
|
|
317
|
+
if (!row.expanded) return { state: expand(state, selectedId), selectedId, handled: true };
|
|
318
|
+
return moved(moveSelection(rows, selectedId, 1));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ArrowLeft: open → close it; closed (or a leaf) → climb to the parent.
|
|
322
|
+
if (row.hasKids && row.expanded) return { state: collapse(state, selectedId), selectedId, handled: true };
|
|
323
|
+
const parent = parentOf(rows, selectedId);
|
|
324
|
+
return parent === null ? unchanged : moved(parent);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- presentation ---------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Kind → glyph.
|
|
331
|
+
*
|
|
332
|
+
* A parallel table to the TUI's `kindStyle` (`src/pi/viewer/tui/theme.ts`),
|
|
333
|
+
* which the client tier may not import. The glyphs are deliberately the same
|
|
334
|
+
* characters: someone who has used `/weave-view tui` should recognise the tree
|
|
335
|
+
* in the browser, and two products' worth of icon vocabulary is a cost with no
|
|
336
|
+
* benefit. Colour is *not* duplicated — that stays in the stylesheet, keyed by
|
|
337
|
+
* {@link TreeRowView.kind}.
|
|
338
|
+
*/
|
|
339
|
+
const KIND_GLYPHS: Readonly<Record<WireNodeKind, string>> = {
|
|
340
|
+
vault: "◆",
|
|
341
|
+
note: "▪",
|
|
342
|
+
repository: "▣",
|
|
343
|
+
module: "▤",
|
|
344
|
+
package: "◈",
|
|
345
|
+
entryPoint: "▷",
|
|
346
|
+
gitState: "◇",
|
|
347
|
+
external: "↗",
|
|
348
|
+
file: "·",
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** The glyph for a node kind. */
|
|
352
|
+
export function kindGlyph(kind: WireNodeKind): string {
|
|
353
|
+
return KIND_GLYPHS[kind];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Provenance → glyph.
|
|
358
|
+
*
|
|
359
|
+
* `●` human, `◐` agent, `○` generated — the TUI's vocabulary again, and the
|
|
360
|
+
* one place AGENTS.md rule 4 shows up in the browser: agent-written content
|
|
361
|
+
* must never look human-authored, so the marker is a filled/half/hollow shape
|
|
362
|
+
* that survives greyscale, colour-blindness and a screenshot, rather than a
|
|
363
|
+
* colour alone. Structural nodes have no provenance and get nothing.
|
|
364
|
+
*/
|
|
365
|
+
const PROVENANCE_GLYPHS: Readonly<Record<WireNoteSource, string>> = {
|
|
366
|
+
human: "●",
|
|
367
|
+
agent: "◐",
|
|
368
|
+
generated: "○",
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** The provenance marker for a row, or `""` for a structural node. */
|
|
372
|
+
export function provenanceGlyph(provenance: WireNoteSource | null): string {
|
|
373
|
+
return provenance === null ? "" : PROVENANCE_GLYPHS[provenance];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The `title=` on the provenance marker. Spells out what the shape means. */
|
|
377
|
+
export function provenanceTitle(provenance: WireNoteSource | null): string {
|
|
378
|
+
return provenance === null ? "" : `${provenance}-authored`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The disclosure state of a row: which triangle, or none. */
|
|
382
|
+
export type Twisty = "open" | "closed" | "leaf";
|
|
383
|
+
|
|
384
|
+
/** `▾` / `▸` / nothing. */
|
|
385
|
+
export const TWISTY_GLYPHS: Readonly<Record<Twisty, string>> = { open: "▾", closed: "▸", leaf: "" };
|
|
386
|
+
|
|
387
|
+
// --- ARIA ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The `id` attribute for a tree row.
|
|
391
|
+
*
|
|
392
|
+
* Needed because focus lives on the `<ul role="tree">`, not on the rows — a
|
|
393
|
+
* roving `tabindex` would put every row in the Tab order and make Tab a
|
|
394
|
+
* fourth way to walk the tree. The standard alternative is
|
|
395
|
+
* `aria-activedescendant`, which is an **id reference**, so the rows need ids.
|
|
396
|
+
*
|
|
397
|
+
* Derived from the row id with everything outside `[A-Za-z0-9_-]` replaced,
|
|
398
|
+
* for `rowDomId`'s reason in `search.model.ts`: a graph id is
|
|
399
|
+
* `module:src/web/client`, which is a legal HTML `id` and an illegal CSS
|
|
400
|
+
* selector, and an assistive technology that builds a selector from the
|
|
401
|
+
* reference gets nothing. The replacement is injective enough for this — two
|
|
402
|
+
* ids differing only in punctuation would collide, and the graph builder
|
|
403
|
+
* derives ids from slugs and paths, where `:` is always the kind separator.
|
|
404
|
+
*/
|
|
405
|
+
export function rowDomId(id: string): string {
|
|
406
|
+
return `weave-row-${id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** `aria-posinset` / `aria-setsize` for one row. */
|
|
410
|
+
export interface TreePosition {
|
|
411
|
+
readonly posinset: number;
|
|
412
|
+
readonly setsize: number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Position-in-set for every row, from the flattened list.
|
|
417
|
+
*
|
|
418
|
+
* A `role="tree"` whose items are not nested in `role="group"` elements is
|
|
419
|
+
* the *flat* tree pattern, and it is the right one here — the rows come from
|
|
420
|
+
* core's `treeRows` already flattened, and rebuilding a nested `<ul>` in the
|
|
421
|
+
* browser would be a second tree structure to keep in agreement with the
|
|
422
|
+
* TUI's. The pattern's cost is that `aria-level` alone tells a screen reader
|
|
423
|
+
* the depth and nothing about extent: "level 2" with no "3 of 7" leaves a
|
|
424
|
+
* user unable to tell a short list from a long one without walking it, which
|
|
425
|
+
* is precisely the orientation a tree is for.
|
|
426
|
+
*
|
|
427
|
+
* ## Why a stack rather than a contiguous run
|
|
428
|
+
*
|
|
429
|
+
* The obvious implementation — "siblings are consecutive rows at the same
|
|
430
|
+
* depth" — is wrong for exactly the shape a tree always has. Given
|
|
431
|
+
*
|
|
432
|
+
* ```text
|
|
433
|
+
* vault depth 0
|
|
434
|
+
* Graph model depth 1
|
|
435
|
+
* Viewer depth 1
|
|
436
|
+
* repository depth 0
|
|
437
|
+
* ```
|
|
438
|
+
*
|
|
439
|
+
* `vault` and `repository` are siblings, and they are *not* consecutive: two
|
|
440
|
+
* children sit between them. A run-based pass reports both as "1 of 1", which
|
|
441
|
+
* is a confidently wrong announcement rather than a missing one. So the open
|
|
442
|
+
* set at each depth is kept on a stack, and a row at depth *d* closes every
|
|
443
|
+
* set deeper than *d* — the standard flattened-tree walk, and the only one
|
|
444
|
+
* that gets the roots right.
|
|
445
|
+
*
|
|
446
|
+
* One pass over the list, because the per-row version is O(rows²) on a
|
|
447
|
+
* repository with a few thousand files.
|
|
448
|
+
*/
|
|
449
|
+
export function treePositions(rows: readonly TreeRow[]): TreePosition[] {
|
|
450
|
+
const out: TreePosition[] = rows.map(() => ({ posinset: 1, setsize: 1 }));
|
|
451
|
+
/** Indices of the still-open sibling set at each depth. */
|
|
452
|
+
const open: number[][] = [];
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Close every set deeper than `depth` and write its members' positions.
|
|
456
|
+
*
|
|
457
|
+
* `splice` rather than a `pop` loop, and that is a coverage decision as
|
|
458
|
+
* much as a brevity one: `open.pop()` is `number[] | undefined` under
|
|
459
|
+
* `noUncheckedIndexedAccess` even where the loop guard has already proved
|
|
460
|
+
* the array non-empty, so the `?? []` it needs is a branch that *cannot*
|
|
461
|
+
* be taken and therefore cannot be covered — a permanent hole in a gate
|
|
462
|
+
* that is supposed to mean something. `splice` returns an array of arrays
|
|
463
|
+
* with no such element. The same reasoning as {@link idAt}'s.
|
|
464
|
+
*/
|
|
465
|
+
const closeBelow = (depth: number): void => {
|
|
466
|
+
for (const set of open.splice(depth + 1)) {
|
|
467
|
+
for (const [index, at] of set.entries()) out[at] = { posinset: index + 1, setsize: set.length };
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
for (const [index, row] of rows.entries()) {
|
|
472
|
+
closeBelow(row.depth);
|
|
473
|
+
// A gap in depth cannot happen from `treeRows` (a child is always exactly
|
|
474
|
+
// one deeper than its parent), but a truncated payload could produce one,
|
|
475
|
+
// and a missing slot would silently drop the row from every set. Filling
|
|
476
|
+
// up to the depth and then reading the *last* slot keeps the access
|
|
477
|
+
// total: `at(-1)` on an array just proved non-empty still needs a guard
|
|
478
|
+
// under `noUncheckedIndexedAccess`, so the set is pushed and reused
|
|
479
|
+
// instead of re-indexed.
|
|
480
|
+
let set = open[row.depth];
|
|
481
|
+
if (set === undefined) {
|
|
482
|
+
set = [];
|
|
483
|
+
while (open.length < row.depth) open.push([]);
|
|
484
|
+
open.push(set);
|
|
485
|
+
}
|
|
486
|
+
set.push(index);
|
|
487
|
+
}
|
|
488
|
+
closeBelow(-1);
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** A `TreeRow`, resolved to the strings and flags a list item renders. */
|
|
493
|
+
export interface TreeRowView {
|
|
494
|
+
readonly id: string;
|
|
495
|
+
/** The `id` attribute, for `aria-activedescendant`. */
|
|
496
|
+
readonly domId: string;
|
|
497
|
+
readonly depth: number;
|
|
498
|
+
readonly label: string;
|
|
499
|
+
readonly kind: WireNodeKind;
|
|
500
|
+
readonly kindGlyph: string;
|
|
501
|
+
readonly provenance: WireNoteSource | null;
|
|
502
|
+
readonly provenanceGlyph: string;
|
|
503
|
+
readonly provenanceTitle: string;
|
|
504
|
+
readonly twisty: Twisty;
|
|
505
|
+
readonly twistyGlyph: string;
|
|
506
|
+
readonly hasKids: boolean;
|
|
507
|
+
readonly expanded: boolean;
|
|
508
|
+
readonly selected: boolean;
|
|
509
|
+
/** The trailing annotation, already formatted against `now`. */
|
|
510
|
+
readonly meta: string;
|
|
511
|
+
/** ARIA `aria-level`, which is 1-based where `depth` is 0-based. */
|
|
512
|
+
readonly level: number;
|
|
513
|
+
/** ARIA `aria-posinset`: which sibling this is, 1-based. */
|
|
514
|
+
readonly posinset: number;
|
|
515
|
+
/** ARIA `aria-setsize`: how many siblings there are. */
|
|
516
|
+
readonly setsize: number;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Resolve one row for rendering.
|
|
521
|
+
*
|
|
522
|
+
* `now` is a parameter rather than a `Date.now()` call, per AGENTS.md: a
|
|
523
|
+
* relative timestamp read off the wall clock is untestable, and this is the
|
|
524
|
+
* function that turns `{kind:"relTime", iso}` into `"12m ago"`.
|
|
525
|
+
*/
|
|
526
|
+
export function rowView(row: TreeRow, selectedId: string | null, now: number, position: TreePosition = { posinset: 1, setsize: 1 }): TreeRowView {
|
|
527
|
+
const twisty: Twisty = !row.hasKids ? "leaf" : row.expanded ? "open" : "closed";
|
|
528
|
+
return {
|
|
529
|
+
id: row.id,
|
|
530
|
+
domId: rowDomId(row.id),
|
|
531
|
+
posinset: position.posinset,
|
|
532
|
+
setsize: position.setsize,
|
|
533
|
+
depth: row.depth,
|
|
534
|
+
label: row.label,
|
|
535
|
+
kind: row.kind,
|
|
536
|
+
kindGlyph: kindGlyph(row.kind),
|
|
537
|
+
provenance: row.provenance,
|
|
538
|
+
provenanceGlyph: provenanceGlyph(row.provenance),
|
|
539
|
+
provenanceTitle: provenanceTitle(row.provenance),
|
|
540
|
+
twisty,
|
|
541
|
+
twistyGlyph: TWISTY_GLYPHS[twisty],
|
|
542
|
+
hasKids: row.hasKids,
|
|
543
|
+
expanded: row.expanded,
|
|
544
|
+
selected: row.id === selectedId,
|
|
545
|
+
meta: formatTreeMeta(row.meta, now),
|
|
546
|
+
level: row.depth + 1,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Resolve a whole row list, positions included. The component's single `map`. */
|
|
551
|
+
export function rowViews(rows: readonly TreeRow[], selectedId: string | null, now: number): TreeRowView[] {
|
|
552
|
+
const positions = treePositions(rows);
|
|
553
|
+
return rows.map((row, index) => rowView(row, selectedId, now, positions[index]));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* `aria-activedescendant` for the tree, or `null`.
|
|
558
|
+
*
|
|
559
|
+
* `null` when the selection is not a *visible* row — the selection is the
|
|
560
|
+
* §1.3 bus and can name a node the tree has filtered away or collapsed under
|
|
561
|
+
* a closed parent. Pointing `aria-activedescendant` at an id that is not in
|
|
562
|
+
* the DOM is worse than omitting it: the attribute is a promise that the
|
|
563
|
+
* element exists, and a screen reader that follows a dangling one announces
|
|
564
|
+
* nothing while the user is certain something is selected.
|
|
565
|
+
*/
|
|
566
|
+
export function treeActiveDescendant(rows: readonly TreeRow[], selectedId: string | null): string | null {
|
|
567
|
+
return indexOfRow(rows, selectedId) === -1 ? null : rowDomId(selectedId as string);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The per-row indent, as a custom property.
|
|
572
|
+
*
|
|
573
|
+
* A number, not a pixel width: the stylesheet multiplies it by an indent step
|
|
574
|
+
* it owns, so the tree's density is a CSS decision and this module only says
|
|
575
|
+
* how deep the row is. Applied through Preact's `style` prop, which reaches
|
|
576
|
+
* the DOM via `CSSStyleDeclaration.setProperty` and is therefore **not**
|
|
577
|
+
* governed by `style-src` — the same CSSOM path `cssvars.ts` documents and
|
|
578
|
+
* verified in `preact/src/diff/props.js`. No `'unsafe-inline'` is involved.
|
|
579
|
+
*/
|
|
580
|
+
export function depthVar(depth: number): Readonly<Record<string, string>> {
|
|
581
|
+
return { "--weave-depth": String(Math.max(0, depth)) };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// --- the control strip -------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
/** The provenance button's label. `all` is the unfiltered state. */
|
|
587
|
+
export function provenanceLabel(provenance: WireNoteSource | null): string {
|
|
588
|
+
return provenance === null ? "all" : provenance;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The provenance button's tooltip — names what pressing it will do next. */
|
|
592
|
+
export function provenanceHint(provenance: WireNoteSource | null): string {
|
|
593
|
+
const at = PROVENANCE_CYCLE.indexOf(provenance);
|
|
594
|
+
const next = PROVENANCE_CYCLE[(at + 1) % PROVENANCE_CYCLE.length] ?? null;
|
|
595
|
+
return `provenance filter: ${provenanceLabel(provenance)} — click for ${provenanceLabel(next)}`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The internals toggle's label. */
|
|
599
|
+
export function internalsLabel(showInternals: boolean): string {
|
|
600
|
+
return showInternals ? "internals" : "knowledge";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** The internals toggle's tooltip. */
|
|
604
|
+
export function internalsHint(showInternals: boolean): string {
|
|
605
|
+
return showInternals
|
|
606
|
+
? "showing git state, packages, externals and entry points — click to hide"
|
|
607
|
+
: "hiding git state, packages, externals and entry points — click to show";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Placeholder for the filter box. */
|
|
611
|
+
export const FILTER_PLACEHOLDER = "filter…";
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The tree's accessible name.
|
|
615
|
+
*
|
|
616
|
+
* Names *what is in it* rather than what it is: "Tree" is already the
|
|
617
|
+
* column's heading and the role announces the widget type, so a second
|
|
618
|
+
* "tree" would be read three times. "Vault and repository" is the sentence
|
|
619
|
+
* §1.1 uses for the same thing.
|
|
620
|
+
*/
|
|
621
|
+
export const TREE_LABEL = "Vault and repository";
|
|
622
|
+
|
|
623
|
+
/** The filter box's `aria-label`, and the hint naming the key that focuses it. */
|
|
624
|
+
export const FILTER_LABEL = "Filter the tree";
|
|
625
|
+
|
|
626
|
+
/** The filter box's `title`. Teaches `/`, which is the global way in. */
|
|
627
|
+
export const FILTER_HINT = "Filter the tree (/)";
|
|
628
|
+
|
|
629
|
+
// --- the empty states ---------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* What the column says instead of rows.
|
|
633
|
+
*
|
|
634
|
+
* Four genuinely different situations, and conflating them is how a UI ends up
|
|
635
|
+
* telling a user their vault is empty because they typed a typo into a filter
|
|
636
|
+
* box. `null` means "there are rows — render them".
|
|
637
|
+
*
|
|
638
|
+
* ## Why core's hint is consulted before the row count
|
|
639
|
+
*
|
|
640
|
+
* The obvious order — "no rows? then work out why" — is wrong here, and it is
|
|
641
|
+
* wrong in the case that matters most. A brand-new vault is not a graph with
|
|
642
|
+
* no rows: `treeRows` still emits the `vault` root, so the column renders one
|
|
643
|
+
* word and nothing else, and a user's first ever session says nothing about
|
|
644
|
+
* how to add a note. `treeEmptyHint` is core's answer to exactly that
|
|
645
|
+
* question, and it is deliberately narrow — it returns a string only for a
|
|
646
|
+
* vault with no notes *and* no repository — so consulting it first cannot
|
|
647
|
+
* suppress a tree that has real content.
|
|
648
|
+
*
|
|
649
|
+
* It also outranks the filter message, for the same reason: when the vault is
|
|
650
|
+
* genuinely empty, "nothing matches this filter" is true and useless.
|
|
651
|
+
*
|
|
652
|
+
* Using core's sentence rather than writing one here is §3: the TUI's empty
|
|
653
|
+
* tree and the browser's say the same thing about the same vault, because
|
|
654
|
+
* there is one sentence.
|
|
655
|
+
*/
|
|
656
|
+
export function treeEmptyMessage(
|
|
657
|
+
payload: GraphPayload | null,
|
|
658
|
+
rows: readonly TreeRow[],
|
|
659
|
+
state: TreeViewState,
|
|
660
|
+
): string | null {
|
|
661
|
+
if (payload === null) return "loading…";
|
|
662
|
+
const hint = treeEmptyHint(viewModel(payload));
|
|
663
|
+
if (hint !== null) return hint;
|
|
664
|
+
if (rows.length > 0) return null;
|
|
665
|
+
if (state.query.length > 0 || state.provFilter !== null) return "nothing matches this filter";
|
|
666
|
+
// No rows, no filter, and core had no opinion — a payload with no roots at
|
|
667
|
+
// all, which the server does not produce but a truncated response could.
|
|
668
|
+
return "nothing to show";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** `34 notes · 127 nodes`-style count line under the tree. Rows, not nodes. */
|
|
672
|
+
export function rowCountLabel(rows: readonly TreeRow[]): string {
|
|
673
|
+
return rows.length === 1 ? "1 row" : `${rows.length} rows`;
|
|
674
|
+
}
|