pi-weave 0.1.5 → 0.1.7
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 +30 -49
- package/package.json +3 -1
- package/skills/weave-explore/SKILL.md +18 -30
- package/skills/weave-notepad/SKILL.md +39 -36
- package/src/pi/index.ts +7 -17
- package/src/pi/viewer/tui/bodyStore.ts +98 -0
- package/src/pi/viewer/tui/branding.ts +154 -0
- package/src/pi/viewer/tui/explorer.ts +16 -29
- package/src/pi/viewer/tui/openNote.ts +60 -0
- package/src/pi/viewer/tui/run.ts +13 -4
- package/src/pi/viewer/tui/surface/base.ts +182 -0
- package/src/pi/viewer/tui/surface/detail.ts +315 -0
- package/src/pi/viewer/tui/surface/explore.ts +204 -0
- package/src/pi/viewer/tui/surface/focus.ts +139 -0
- package/src/pi/viewer/tui/surface/health.ts +102 -0
- package/src/pi/viewer/tui/workspace.ts +381 -0
- package/src/pi/viewer/tui/workspaceRoot.ts +530 -0
- package/src/pi/viewer/browser.ts +0 -29
- package/src/pi/viewer/page.ts +0 -1316
- package/src/pi/viewer/server.ts +0 -229
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface/detail.ts — the Detail surface as a Component (weave-view-tui-v2 §6).
|
|
3
|
+
*
|
|
4
|
+
* Shows a node's meta, its (real Markdown) body, and links/backlinks. Each
|
|
5
|
+
* Detail pane owns its own bound node + selection/scroll. Enter on a link
|
|
6
|
+
* rebinds the pane in place (decision: a Detail pane re-navigates within
|
|
7
|
+
* itself); the body is rendered through the pi-tui `Markdown` component with
|
|
8
|
+
* a MarkdownTheme mapped from the weave theme (decision 7).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Markdown, matchesKey, type MarkdownTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { detailModel, sanitizeTerminalText, type DetailModel } from "../model";
|
|
13
|
+
import { kindStyle, provenanceStyle, type ThemeSlot } from "../theme";
|
|
14
|
+
import { type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
|
|
15
|
+
import type { GraphModel } from "../../../../core/graph/model";
|
|
16
|
+
import type { NoteSource } from "../../../../core/types";
|
|
17
|
+
|
|
18
|
+
/** A flattened Detail render line. `id` marks a selectable row (meta/links/
|
|
19
|
+
* backlinks); `target` is the node id an Enter on that row rebinds to. Both
|
|
20
|
+
* are optional and may be explicitly `undefined` (body/header lines have
|
|
21
|
+
* neither) — declared `| undefined` so the object literals below type-check
|
|
22
|
+
* under `exactOptionalPropertyTypes`. */
|
|
23
|
+
type DetailLine = { text: string; id?: string | undefined; target?: string | undefined };
|
|
24
|
+
|
|
25
|
+
export interface DetailSurfaceState {
|
|
26
|
+
nodeId: string | null;
|
|
27
|
+
selectedId: string | null;
|
|
28
|
+
scrollOffset: number;
|
|
29
|
+
/** Goto-line buffer: `null`/undefined = not in goto mode; a string (possibly
|
|
30
|
+
* empty) = the user is typing a line number to jump to. `/` enters goto,
|
|
31
|
+
* digits append, Enter jumps, Backspace deletes, Esc cancels. The workspace
|
|
32
|
+
* root treats a non-null `gotoBuf` as a sub-mode so Esc cancels instead of
|
|
33
|
+
* quitting (mirrors Explore's `searching`). */
|
|
34
|
+
gotoBuf?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function bindDetail(init: SurfaceInit, nodeId: string): DetailSurface {
|
|
38
|
+
const s = new DetailSurface(init);
|
|
39
|
+
s.state = { nodeId, selectedId: nodeId, scrollOffset: 0 };
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DetailSurface implements Surface {
|
|
44
|
+
readonly kind = "detail" as const;
|
|
45
|
+
private readonly ctx;
|
|
46
|
+
private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
|
|
47
|
+
state: DetailSurfaceState;
|
|
48
|
+
paneRows = 24;
|
|
49
|
+
/** Cached flattened render lines (with ids/targets) from the last render, so
|
|
50
|
+
* handleInput can compute the viewport center / active row + max scroll
|
|
51
|
+
* offset without re-rendering. Bust on invalidate / rebind. */
|
|
52
|
+
private _lines: DetailLine[] | undefined;
|
|
53
|
+
|
|
54
|
+
constructor(init: SurfaceInit) {
|
|
55
|
+
this.ctx = init.context;
|
|
56
|
+
this.onEvent = init.onEvent;
|
|
57
|
+
this.state = { nodeId: init.context.model.nodes[0]?.id ?? null, selectedId: null, scrollOffset: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
title(): string {
|
|
61
|
+
return "Detail";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setFocused(_focused: boolean): void {
|
|
65
|
+
// detail has no focus-only state
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
invalidate(): void {
|
|
69
|
+
// Drop the cached line layout so the next render rebuilds it from the
|
|
70
|
+
// current body (BodyStore loads fire onChange at the root → invalidate).
|
|
71
|
+
this._lines = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
rebind(model: GraphModel): void {
|
|
75
|
+
// After a refresh, a detail pane may be bound to a node that was removed.
|
|
76
|
+
// Rebind to the first surviving node so the pane never shows a stale
|
|
77
|
+
// "(node not found)" after the model is rebuilt.
|
|
78
|
+
if (this.state.nodeId && !model.nodes.some((n) => n.id === this.state.nodeId)) {
|
|
79
|
+
const first = model.nodes[0]?.id ?? null;
|
|
80
|
+
this.state = { nodeId: first, selectedId: first, scrollOffset: 0, gotoBuf: null };
|
|
81
|
+
this._lines = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleInput(data: string): void {
|
|
86
|
+
const paneRows = Math.max(5, this.paneRows);
|
|
87
|
+
if (!this._lines) this._lines = this.buildLines(80);
|
|
88
|
+
const maxOffset = Math.max(0, this._lines.length - paneRows);
|
|
89
|
+
const clamp = (n: number): number => Math.max(0, Math.min(maxOffset, n));
|
|
90
|
+
|
|
91
|
+
// Goto-line sub-mode: digits build a line number, Enter jumps, Esc/bs exit.
|
|
92
|
+
if (this.state.gotoBuf != null) {
|
|
93
|
+
if (matchesKey(data, "escape")) {
|
|
94
|
+
this.state = { ...this.state, gotoBuf: null };
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (matchesKey(data, "enter")) {
|
|
98
|
+
const n = parseInt(this.state.gotoBuf, 10);
|
|
99
|
+
this.state = { ...this.state, scrollOffset: clamp(Number.isNaN(n) || n < 1 ? 0 : n - 1), gotoBuf: null };
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (matchesKey(data, "backspace")) {
|
|
103
|
+
const b = this.state.gotoBuf.slice(0, -1);
|
|
104
|
+
this.state = { ...this.state, gotoBuf: b.length ? b : null };
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (data.length === 1 && data >= "0" && data <= "9" && this.state.gotoBuf.length < 5) {
|
|
108
|
+
this.state = { ...this.state, gotoBuf: this.state.gotoBuf + data };
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
return; // ignore unmapped keys while typing a line number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (data === "o") {
|
|
115
|
+
if (this.state.nodeId) this.onEvent?.({ type: "openEditor", id: this.state.nodeId });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Scroll-primary: arrows/page/home/end move the viewport; the selectable
|
|
119
|
+
// row nearest the viewport center is the active (›) row — Enter opens it.
|
|
120
|
+
if (matchesKey(data, "up")) {
|
|
121
|
+
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset - 1) };
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (matchesKey(data, "down")) {
|
|
125
|
+
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset + 1) };
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (matchesKey(data, "pageUp")) {
|
|
129
|
+
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset - paneRows) };
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (matchesKey(data, "pageDown")) {
|
|
133
|
+
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset + paneRows) };
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (matchesKey(data, "home")) {
|
|
137
|
+
this.state = { ...this.state, scrollOffset: 0 };
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (matchesKey(data, "end")) {
|
|
141
|
+
this.state = { ...this.state, scrollOffset: maxOffset };
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (data === "/") {
|
|
145
|
+
this.state = { ...this.state, gotoBuf: "" };
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (matchesKey(data, "enter")) {
|
|
149
|
+
const row = this.activeRow();
|
|
150
|
+
if (row?.target) {
|
|
151
|
+
// rebind this detail pane to the target (Obsidian "open in main" in place)
|
|
152
|
+
this.state = { nodeId: row.target, selectedId: row.target, scrollOffset: 0, gotoBuf: null };
|
|
153
|
+
this._lines = undefined;
|
|
154
|
+
this.requestBody(row.target);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The selectable row (meta/links/backlinks) nearest the viewport center. */
|
|
161
|
+
private activeRow(): { id: string; target?: string | undefined } | undefined {
|
|
162
|
+
const lines = this._lines;
|
|
163
|
+
if (!lines) return undefined;
|
|
164
|
+
const paneRows = Math.max(5, this.paneRows);
|
|
165
|
+
const center = this.state.scrollOffset + Math.floor(paneRows / 2);
|
|
166
|
+
let bestI = -1;
|
|
167
|
+
let best = Infinity;
|
|
168
|
+
for (let i = 0; i < lines.length; i++) {
|
|
169
|
+
const ln = lines[i]!;
|
|
170
|
+
if (!ln.id) continue;
|
|
171
|
+
const d = Math.abs(i - center);
|
|
172
|
+
if (d < best) {
|
|
173
|
+
best = d;
|
|
174
|
+
bestI = i;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return bestI >= 0 ? { id: lines[bestI]!.id!, target: lines[bestI]!.target } : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Flatten the detail (title + meta + body + links + backlinks) into lines. */
|
|
181
|
+
private buildLines(width: number): DetailLine[] {
|
|
182
|
+
const t = this.ctx.theme;
|
|
183
|
+
const id = this.state.nodeId;
|
|
184
|
+
if (!id) return [];
|
|
185
|
+
const d = detailModel(this.ctx.model, id);
|
|
186
|
+
if (!d) return [];
|
|
187
|
+
const lines: DetailLine[] = [];
|
|
188
|
+
const marker = this.rowMarker(d.kind, d.provenance);
|
|
189
|
+
const provBadge = d.provenance ? ` ${provenanceStyle(d.provenance).glyph} ${provenanceStyle(d.provenance).word}` : "";
|
|
190
|
+
lines.push({ text: t.bold(`${marker}${sanitizeTerminalText(d.label)}`) + t.fg("muted", ` (${d.kind}${provBadge})`) });
|
|
191
|
+
for (const m of d.meta) {
|
|
192
|
+
lines.push({ text: `${t.fg("muted", sanitizeTerminalText(m.label))}: ${sanitizeTerminalText(m.value)}`, id: m.id });
|
|
193
|
+
}
|
|
194
|
+
const body = this.bodyLinesFor(id, width);
|
|
195
|
+
for (const b of body) lines.push({ text: b });
|
|
196
|
+
if (d.links.length > 0) {
|
|
197
|
+
lines.push({ text: t.fg("accent", "Links") });
|
|
198
|
+
for (const lk of d.links) {
|
|
199
|
+
lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, id: lk.id, target: lk.target });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (d.backlinks.length > 0) {
|
|
203
|
+
lines.push({ text: t.fg("accent", "Backlinks") });
|
|
204
|
+
for (const lk of d.backlinks) {
|
|
205
|
+
lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, id: lk.id, target: lk.target });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
render(width: number): string[] {
|
|
212
|
+
const t = this.ctx.theme;
|
|
213
|
+
const id = this.state.nodeId;
|
|
214
|
+
if (!id) return [t.fg("dim", "(no selection)")];
|
|
215
|
+
const d = detailModel(this.ctx.model, id);
|
|
216
|
+
if (!d) return [t.fg("dim", "(node not found)")];
|
|
217
|
+
|
|
218
|
+
const lines = this.buildLines(width);
|
|
219
|
+
this._lines = lines;
|
|
220
|
+
const paneRows = Math.max(5, this.paneRows);
|
|
221
|
+
const maxOffset = Math.max(0, lines.length - paneRows);
|
|
222
|
+
const scrollOffset = Math.max(0, Math.min(this.state.scrollOffset, maxOffset));
|
|
223
|
+
if (scrollOffset !== this.state.scrollOffset) {
|
|
224
|
+
this.state = { ...this.state, scrollOffset };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Active row = selectable row nearest the viewport center.
|
|
228
|
+
const center = scrollOffset + Math.floor(paneRows / 2);
|
|
229
|
+
let activeI = -1;
|
|
230
|
+
let best = Infinity;
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
if (!lines[i]!.id) continue;
|
|
233
|
+
const dist = Math.abs(i - center);
|
|
234
|
+
if (dist < best) {
|
|
235
|
+
best = dist;
|
|
236
|
+
activeI = i;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const activeId = activeI >= 0 ? (lines[activeI]!.id ?? null) : null;
|
|
240
|
+
if (this.state.selectedId !== activeId) {
|
|
241
|
+
this.state = { ...this.state, selectedId: activeId };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const inGoto = this.state.gotoBuf != null;
|
|
245
|
+
// Reserve the last viewport line for the goto prompt when active.
|
|
246
|
+
const contentRows = inGoto ? Math.max(1, paneRows - 1) : paneRows;
|
|
247
|
+
const start = scrollOffset;
|
|
248
|
+
const end = Math.min(lines.length, start + contentRows);
|
|
249
|
+
const marker2 = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
|
|
250
|
+
const out: string[] = [];
|
|
251
|
+
for (let i = start; i < end; i++) {
|
|
252
|
+
out.push(i === activeI ? marker2(lines[i]!.text) : ` ${lines[i]!.text}`);
|
|
253
|
+
}
|
|
254
|
+
if (inGoto) {
|
|
255
|
+
out.push(`${t.fg("accent", "/")}${this.state.gotoBuf}`);
|
|
256
|
+
}
|
|
257
|
+
return out.map((l) => truncateToWidth(l, width));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private rowMarker(kind: string, prov: NoteSource | null): string {
|
|
261
|
+
const ks = kindStyle(kind as never);
|
|
262
|
+
if (kind === "note") {
|
|
263
|
+
const ps = provenanceStyle(prov);
|
|
264
|
+
return ps.glyph ? `${ps.glyph} ` : "";
|
|
265
|
+
}
|
|
266
|
+
return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private requestBody(id: string): void {
|
|
270
|
+
const node = this.ctx.model.nodes.find((n) => n.id === id);
|
|
271
|
+
if (!node) return;
|
|
272
|
+
if (node.kind === "note") this.ctx.bodies.load(id, "note", node.detail.slug);
|
|
273
|
+
else if (node.kind === "file") this.ctx.bodies.load(id, "file", node.detail.path);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The node body, rendered through the real Markdown component (decision 7). */
|
|
277
|
+
private bodyLinesFor(id: string, width: number): string[] {
|
|
278
|
+
const node = this.ctx.model.nodes.find((n) => n.id === id);
|
|
279
|
+
if (!node || (node.kind !== "note" && node.kind !== "file")) return [];
|
|
280
|
+
if (this.ctx.bodies.isLoading(id)) return [this.ctx.theme.fg("dim", "(loading…)")];
|
|
281
|
+
const body = this.ctx.bodies.get(id);
|
|
282
|
+
if (body === undefined) {
|
|
283
|
+
queueMicrotask(() => this.requestBody(id));
|
|
284
|
+
return [this.ctx.theme.fg("dim", "(loading…)")];
|
|
285
|
+
}
|
|
286
|
+
if (body === null || body === "") return [];
|
|
287
|
+
const md = new Markdown(sanitizeTerminalText(body), 0, 0, markdownTheme(this.ctx.theme), { color: (t) => this.ctx.theme.fg("text", t) }, { renderLatex: false });
|
|
288
|
+
return md.render(Math.max(10, width - 2)).map((l) => ` ${l}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Map the weave theme slots onto a pi-tui MarkdownTheme. */
|
|
293
|
+
export function markdownTheme(theme: {
|
|
294
|
+
fg: (slot: ThemeSlot, text: string) => string;
|
|
295
|
+
bold: (text: string) => string;
|
|
296
|
+
}): MarkdownTheme {
|
|
297
|
+
return {
|
|
298
|
+
heading: (t) => theme.bold(t),
|
|
299
|
+
link: (t) => theme.fg("accent", t),
|
|
300
|
+
linkUrl: (t) => theme.fg("muted", t),
|
|
301
|
+
code: (t) => theme.fg("warning", t),
|
|
302
|
+
codeBlock: (t) => theme.fg("text", t),
|
|
303
|
+
codeBlockBorder: (t) => theme.fg("dim", t),
|
|
304
|
+
quote: (t) => theme.fg("dim", t),
|
|
305
|
+
quoteBorder: (t) => theme.fg("dim", t),
|
|
306
|
+
hr: (t) => theme.fg("dim", t),
|
|
307
|
+
listBullet: (t) => theme.fg("accent", t),
|
|
308
|
+
bold: (t) => theme.bold(t),
|
|
309
|
+
italic: (t) => t,
|
|
310
|
+
strikethrough: (t) => t,
|
|
311
|
+
underline: (t) => t,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type { DetailModel, GraphModel };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface/explore.ts — the Explore surface as a Component (weave-view-tui-v2 §6).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the v1 tree view-model (`treeRows` + `reduce`/`ExplorerState`) so each
|
|
5
|
+
* Explore pane owns its own selection, expansion, filter, provenance cycle,
|
|
6
|
+
* and search sub-mode — independent of other panes. Cross-pane navigation
|
|
7
|
+
* (enter → open detail, f → open focus) is emitted as a `SurfaceEvent` that
|
|
8
|
+
* the workspace root resolves (e.g. into the nearest Detail pane).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { decodeAction } from "../explorer";
|
|
13
|
+
import {
|
|
14
|
+
graphRoots,
|
|
15
|
+
initialState,
|
|
16
|
+
reduce,
|
|
17
|
+
treeEmptyHint,
|
|
18
|
+
treeRows,
|
|
19
|
+
type Action,
|
|
20
|
+
type ExplorerState,
|
|
21
|
+
} from "../model";
|
|
22
|
+
import { chevron, kindStyle, provenanceStyle, SELECTION_MARKER, type ThemeSlot } from "../theme";
|
|
23
|
+
import type { GraphModel } from "../../../../core/graph/model";
|
|
24
|
+
import type { NoteSource } from "../../../../core/types";
|
|
25
|
+
import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit, type SurfaceRender } from "./base";
|
|
26
|
+
|
|
27
|
+
/** The Explore surface's own state (wraps the v1 ExplorerState for the tree). */
|
|
28
|
+
export interface ExploreSurfaceState {
|
|
29
|
+
selectedId: string | null;
|
|
30
|
+
expanded: Set<string>;
|
|
31
|
+
showInternals: boolean;
|
|
32
|
+
provFilter: NoteSource | null;
|
|
33
|
+
query: string;
|
|
34
|
+
searching: boolean;
|
|
35
|
+
scrollOffset: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function initialExploreState(model: GraphModel): ExploreSurfaceState {
|
|
39
|
+
const s = initialState(graphRoots(model));
|
|
40
|
+
return {
|
|
41
|
+
selectedId: s.selectedId,
|
|
42
|
+
expanded: s.expanded,
|
|
43
|
+
showInternals: s.showInternals,
|
|
44
|
+
provFilter: s.provFilter,
|
|
45
|
+
query: s.query,
|
|
46
|
+
searching: s.searching,
|
|
47
|
+
scrollOffset: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ExploreSurface implements Surface {
|
|
52
|
+
readonly kind = "explore" as const;
|
|
53
|
+
private readonly ctx;
|
|
54
|
+
private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
|
|
55
|
+
state: ExploreSurfaceState;
|
|
56
|
+
private focused = false;
|
|
57
|
+
/** Pane viewport height (rows); set by the workspace root. */
|
|
58
|
+
paneRows = 24;
|
|
59
|
+
private renderCache = new Map<string, string[]>();
|
|
60
|
+
|
|
61
|
+
constructor(init: SurfaceInit) {
|
|
62
|
+
this.ctx = init.context;
|
|
63
|
+
this.onEvent = init.onEvent;
|
|
64
|
+
this.state = initialExploreState(this.ctx.model);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
title(): string {
|
|
68
|
+
return "Explore";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setFocused(focused: boolean): void {
|
|
72
|
+
this.focused = focused;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
invalidate(): void {
|
|
76
|
+
this.renderCache.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Navigate the tree with a key sequence (delegates to v1 decodeAction + reduce). */
|
|
80
|
+
handleInput(data: string): void {
|
|
81
|
+
// o opens the selected node in the external editor (parity with v1's
|
|
82
|
+
// WeaveExplorer and the Detail surface; the workspace root resolves the
|
|
83
|
+
// event into loaders.openNote/openFile).
|
|
84
|
+
if (data === "o" && !this.state.searching) {
|
|
85
|
+
if (this.state.selectedId) this.onEvent?.({ type: "openEditor", id: this.state.selectedId });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const action = decodeAction(data, toExplorerState(this.state));
|
|
89
|
+
if (!action) return;
|
|
90
|
+
this.applyAction(action);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private applyAction(action: Action): void {
|
|
94
|
+
// Cross-pane navigation: enter → open detail; f → open focus.
|
|
95
|
+
if (action.type === "enter" && !this.state.searching) {
|
|
96
|
+
if (this.state.selectedId) this.onEvent?.({ type: "openDetail", id: this.state.selectedId });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (action.type === "focus" && !this.state.searching) {
|
|
100
|
+
if (this.state.selectedId) this.onEvent?.({ type: "focusNode", id: this.state.selectedId });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const rows = this.renderRows(80);
|
|
104
|
+
const prev = toExplorerState(this.state);
|
|
105
|
+
const next = reduce(prev, action, { rows, window: Math.max(5, this.paneRows) });
|
|
106
|
+
this.state = fromExplorerState(next);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private renderRows(width: number): { id: string; target?: string }[] {
|
|
110
|
+
return this.renderSurface(width).rows;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
render(width: number): string[] {
|
|
114
|
+
const key = `${width}:${this.state.scrollOffset}:${this.state.selectedId}:${this.state.query}:${this.state.provFilter ?? ""}:${this.state.showInternals}:${this.state.searching}`;
|
|
115
|
+
const cached = this.renderCache.get(key);
|
|
116
|
+
if (cached) return cached;
|
|
117
|
+
const out = this.renderSurface(width).lines.map((l) => truncateToWidth(l, width));
|
|
118
|
+
this.renderCache.set(key, out);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private renderSurface(width: number): SurfaceRender {
|
|
123
|
+
const t = this.ctx.theme;
|
|
124
|
+
const rows = treeRows(this.ctx.model, {
|
|
125
|
+
expanded: this.state.expanded,
|
|
126
|
+
showInternals: this.state.showInternals,
|
|
127
|
+
provFilter: this.state.provFilter,
|
|
128
|
+
query: this.state.query,
|
|
129
|
+
now: this.ctx.now(),
|
|
130
|
+
});
|
|
131
|
+
const hint = treeEmptyHint(this.ctx.model);
|
|
132
|
+
if (rows.length === 0 && hint) {
|
|
133
|
+
return { lines: [t.fg("dim", hint)], rows: [] };
|
|
134
|
+
}
|
|
135
|
+
const lines: { text: string; id: string }[] = rows.map((r) => {
|
|
136
|
+
const chev = chevron(r.expanded, r.hasKids);
|
|
137
|
+
const marker = this.rowMarker(r.kind, r.provenance);
|
|
138
|
+
const label = sanitize(r.label);
|
|
139
|
+
const meta = r.meta ? t.fg("dim", ` ${sanitize(r.meta)}`) : "";
|
|
140
|
+
const indent = " ".repeat(r.depth);
|
|
141
|
+
const body = `${indent}${chev} ${marker}${label}`;
|
|
142
|
+
return { text: meta ? `${body}${meta}` : body, id: r.id };
|
|
143
|
+
});
|
|
144
|
+
const selId = this.state.selectedId;
|
|
145
|
+
const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
|
|
146
|
+
const marker = (text: string) => `${SELECTION_MARKER} ${this.ctx.theme.bg("selectedBg", text)}`;
|
|
147
|
+
const out: string[] = windowLines(
|
|
148
|
+
lines.map((l) => l.text),
|
|
149
|
+
selLine,
|
|
150
|
+
this.state.scrollOffset,
|
|
151
|
+
Math.max(5, this.paneRows),
|
|
152
|
+
marker,
|
|
153
|
+
);
|
|
154
|
+
if (this.state.searching) {
|
|
155
|
+
const prompt = t.fg("accent", "/");
|
|
156
|
+
out.push(`${prompt}${sanitize(this.state.query)}`);
|
|
157
|
+
}
|
|
158
|
+
return { lines: out, rows: rows.map((r) => ({ id: r.id })) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private rowMarker(kind: string, prov: NoteSource | null): string {
|
|
162
|
+
const ks = kindStyle(kind as never);
|
|
163
|
+
if (kind === "note") {
|
|
164
|
+
const ps = provenanceStyle(prov);
|
|
165
|
+
return ps.glyph ? `${ps.glyph} ` : "";
|
|
166
|
+
}
|
|
167
|
+
return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sanitize(s: string): string {
|
|
172
|
+
// eslint-disable-next-line no-control-regex
|
|
173
|
+
return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toExplorerState(s: ExploreSurfaceState): ExplorerState {
|
|
177
|
+
return {
|
|
178
|
+
surface: "tree",
|
|
179
|
+
searching: s.searching,
|
|
180
|
+
selectedId: s.selectedId,
|
|
181
|
+
focusId: null,
|
|
182
|
+
detailId: null,
|
|
183
|
+
expanded: s.expanded,
|
|
184
|
+
showInternals: s.showInternals,
|
|
185
|
+
provFilter: s.provFilter,
|
|
186
|
+
query: s.query,
|
|
187
|
+
helpOpen: false,
|
|
188
|
+
refreshing: false,
|
|
189
|
+
version: 0,
|
|
190
|
+
scrollOffset: s.scrollOffset,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fromExplorerState(s: ExplorerState): ExploreSurfaceState {
|
|
195
|
+
return {
|
|
196
|
+
selectedId: s.selectedId,
|
|
197
|
+
expanded: s.expanded,
|
|
198
|
+
showInternals: s.showInternals,
|
|
199
|
+
provFilter: s.provFilter,
|
|
200
|
+
query: s.query,
|
|
201
|
+
searching: s.searching,
|
|
202
|
+
scrollOffset: s.scrollOffset,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface/focus.ts — the Focus surface as a Component (weave-view-tui-v2 §6).
|
|
3
|
+
*
|
|
4
|
+
* The 1-hop graph neighborhood (`focusModel`). Enter re-centers the focus on
|
|
5
|
+
* the selected neighbor, walking the graph. Each Focus pane owns its own
|
|
6
|
+
* center node + selection/scroll.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { focusModel, sanitizeTerminalText } from "../model";
|
|
11
|
+
import { kindStyle, provenanceStyle, type ThemeSlot } from "../theme";
|
|
12
|
+
import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
|
|
13
|
+
import type { NoteSource } from "../../../../core/types";
|
|
14
|
+
import type { GraphModel } from "../../../../core/graph/model";
|
|
15
|
+
|
|
16
|
+
export interface FocusSurfaceState {
|
|
17
|
+
focusId: string | null;
|
|
18
|
+
selectedId: string | null;
|
|
19
|
+
scrollOffset: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class FocusSurface implements Surface {
|
|
23
|
+
readonly kind = "focus" as const;
|
|
24
|
+
private readonly ctx;
|
|
25
|
+
private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
|
|
26
|
+
state: FocusSurfaceState;
|
|
27
|
+
paneRows = 24;
|
|
28
|
+
|
|
29
|
+
constructor(init: SurfaceInit) {
|
|
30
|
+
this.ctx = init.context;
|
|
31
|
+
this.onEvent = init.onEvent;
|
|
32
|
+
this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bind the focus center node (called when opening/`f`). */
|
|
36
|
+
setFocus(nodeId: string): void {
|
|
37
|
+
this.state = { focusId: nodeId, selectedId: nodeId, scrollOffset: 0 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setFocused(_focused: boolean): void {}
|
|
41
|
+
|
|
42
|
+
title(): string {
|
|
43
|
+
return "Focus";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
invalidate(): void {}
|
|
47
|
+
|
|
48
|
+
rebind(model: GraphModel): void {
|
|
49
|
+
// After a refresh, drop a focus node that no longer exists rather than
|
|
50
|
+
// rendering an empty "(no focus node)" pane.
|
|
51
|
+
if (this.state.focusId && !model.nodes.some((n) => n.id === this.state.focusId)) {
|
|
52
|
+
this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
handleInput(data: string): void {
|
|
57
|
+
if (matchesKey(data, "up")) return this.move(-1);
|
|
58
|
+
if (matchesKey(data, "down")) return this.move(1);
|
|
59
|
+
if (matchesKey(data, "pageUp")) return this.movePage(-1);
|
|
60
|
+
if (matchesKey(data, "pageDown")) return this.movePage(1);
|
|
61
|
+
if (matchesKey(data, "home")) {
|
|
62
|
+
this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (matchesKey(data, "end")) {
|
|
66
|
+
const r = this.rows();
|
|
67
|
+
const last = r[r.length - 1];
|
|
68
|
+
this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (matchesKey(data, "enter")) {
|
|
72
|
+
const rows = this.rows();
|
|
73
|
+
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
74
|
+
const row = idx >= 0 ? rows[idx] : undefined;
|
|
75
|
+
if (row && row.target && row.target !== this.state.focusId) {
|
|
76
|
+
this.state = { focusId: row.target, selectedId: row.target, scrollOffset: 0 };
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (data === "g") {
|
|
81
|
+
this.onEvent?.({ type: "focusNode", id: this.state.focusId ?? "" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private move(delta: 1 | -1): void {
|
|
87
|
+
const rows = this.rows();
|
|
88
|
+
if (rows.length === 0) return;
|
|
89
|
+
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
90
|
+
const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
|
|
91
|
+
const row = rows[nextIdx]!;
|
|
92
|
+
this.state = { ...this.state, selectedId: row.id };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private movePage(dir: 1 | -1): void {
|
|
96
|
+
const rows = this.rows();
|
|
97
|
+
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
98
|
+
const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
|
|
99
|
+
const row = rows[target];
|
|
100
|
+
if (row) this.state = { ...this.state, selectedId: row.id };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private rows(): { id: string; target?: string }[] {
|
|
104
|
+
if (!this.state.focusId) return [];
|
|
105
|
+
const f = focusModel(this.ctx.model, this.state.focusId);
|
|
106
|
+
const center: { id: string; target?: string } = f.center.target ? { id: f.center.id, target: f.center.target } : { id: f.center.id };
|
|
107
|
+
return [center, ...f.groups.flatMap((g) => g.rows.map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })))];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
render(width: number): string[] {
|
|
111
|
+
const t = this.ctx.theme;
|
|
112
|
+
const id = this.state.focusId;
|
|
113
|
+
if (!id) return [t.fg("dim", "(no focus node)")];
|
|
114
|
+
const f = focusModel(this.ctx.model, id);
|
|
115
|
+
const lines: { text: string; id?: string }[] = [];
|
|
116
|
+
const cm = this.rowMarker(f.center.kind, f.center.provenance);
|
|
117
|
+
lines.push({ text: t.bold(`${cm}${sanitizeTerminalText(f.center.label)}`) + t.fg("dim", " (focus — g/esc to exit)"), id: f.center.id });
|
|
118
|
+
for (const g of f.groups) {
|
|
119
|
+
lines.push({ text: t.fg("accent", g.heading) });
|
|
120
|
+
for (const r of g.rows) {
|
|
121
|
+
lines.push({ text: ` ${this.rowMarker(r.kind, r.provenance)}${sanitizeTerminalText(r.label)}`, id: r.id });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const selId = this.state.selectedId;
|
|
125
|
+
const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
|
|
126
|
+
const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
|
|
127
|
+
const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
|
|
128
|
+
return windowed.map((l) => truncateToWidth(l, width));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private rowMarker(kind: string, prov: NoteSource | null): string {
|
|
132
|
+
const ks = kindStyle(kind as never);
|
|
133
|
+
if (kind === "note") {
|
|
134
|
+
const ps = provenanceStyle(prov);
|
|
135
|
+
return ps.glyph ? `${ps.glyph} ` : "";
|
|
136
|
+
}
|
|
137
|
+
return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
|
|
138
|
+
}
|
|
139
|
+
}
|