pi-weave 0.1.6 → 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 +6 -9
- package/package.json +1 -1
- 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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface/base.ts — the shared Pane<Surface> wrapper (weave-view-tui-v2 §6).
|
|
3
|
+
*
|
|
4
|
+
* A **Surface** is a harness-free, self-owned component: it holds its own
|
|
5
|
+
* selection/scroll/filter/expansion state, renders its lines, and handles its
|
|
6
|
+
* own keys. Surfaces share the GraphModel, BodyStore, and loaders via a
|
|
7
|
+
* `SurfaceContext`. A **Pane** is a `Component` that hosts a Surface inside a
|
|
8
|
+
* bordered Box with a title and a focus ring (accent when active, dim `line`
|
|
9
|
+
* when inactive — conveyed by border + a leading `◆`, not color alone, per
|
|
10
|
+
* §10).
|
|
11
|
+
*
|
|
12
|
+
* The pane owns scrolling via the injected scroll/offset on the surface; the
|
|
13
|
+
* workspace root routes input to the single active pane's surface.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { GraphModel } from "../../../../core/graph/model";
|
|
18
|
+
import { BodyStore } from "../bodyStore";
|
|
19
|
+
import type { WeaveLoaders, WeaveTheme } from "../explorer";
|
|
20
|
+
import type { SurfaceKind } from "../workspace";
|
|
21
|
+
import type { ThemeSlot } from "../theme";
|
|
22
|
+
|
|
23
|
+
/** A cross-pane navigation request a surface emits (resolved by the root). */
|
|
24
|
+
export type SurfaceEvent =
|
|
25
|
+
| { type: "openDetail"; id: string }
|
|
26
|
+
| { type: "focusNode"; id: string }
|
|
27
|
+
| { type: "openEditor"; id: string };
|
|
28
|
+
|
|
29
|
+
/** A surface emits events (e.g. "open detail") that the workspace root resolves. */
|
|
30
|
+
export type SurfaceEventHandler = (event: SurfaceEvent) => void;
|
|
31
|
+
|
|
32
|
+
/** Shared, session-level deps every surface reads. */
|
|
33
|
+
export interface SurfaceContext {
|
|
34
|
+
model: GraphModel;
|
|
35
|
+
theme: WeaveTheme;
|
|
36
|
+
loaders: WeaveLoaders;
|
|
37
|
+
bodies: BodyStore;
|
|
38
|
+
/** Reference clock (epoch ms); defaults to Date.now at the root. */
|
|
39
|
+
now: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A surface can also be constructed with a render callback (test seam). */
|
|
43
|
+
export interface SurfaceInit {
|
|
44
|
+
context: SurfaceContext;
|
|
45
|
+
/** Emit a cross-pane navigation event. */
|
|
46
|
+
onEvent?: SurfaceEventHandler;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a surface component must expose on top of the pi-tui Component contract. */
|
|
50
|
+
export interface Surface extends Component {
|
|
51
|
+
/** The surface kind this hosts. */
|
|
52
|
+
readonly kind: SurfaceKind;
|
|
53
|
+
/** Called by the workspace root when focus moves to/from this surface. */
|
|
54
|
+
setFocused(focused: boolean): void;
|
|
55
|
+
/** The pane title (surface name), shown in the border. */
|
|
56
|
+
title(): string;
|
|
57
|
+
/**
|
|
58
|
+
* Called after a refresh rebuilds the model, so a surface bound to a node
|
|
59
|
+
* that no longer exists can rebind to a survivor instead of showing
|
|
60
|
+
* "(not found)". Optional — surfaces with no node binding omit it.
|
|
61
|
+
*/
|
|
62
|
+
rebind?(model: GraphModel): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Minimal theme surface the pane chrome needs. */
|
|
66
|
+
export interface PaneTheme {
|
|
67
|
+
fg(slot: ThemeSlot, text: string): string;
|
|
68
|
+
bold(text: string): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A selectable row within a rendered surface (mirrors model.SelectableRow). */
|
|
72
|
+
export interface PaneRow {
|
|
73
|
+
id: string;
|
|
74
|
+
/** Node id a row jumps to on enter (links/backlinks/neighbors). */
|
|
75
|
+
target?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The render result of a surface: lines plus the selectable rows. */
|
|
79
|
+
export interface SurfaceRender {
|
|
80
|
+
lines: string[];
|
|
81
|
+
rows: PaneRow[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A generic selectable, windowed body builder used by the surfaces. */
|
|
85
|
+
export function windowLines(
|
|
86
|
+
lines: string[],
|
|
87
|
+
selLine: number,
|
|
88
|
+
scrollOffset: number,
|
|
89
|
+
window: number,
|
|
90
|
+
marker: (text: string) => string,
|
|
91
|
+
): string[] {
|
|
92
|
+
const win = Math.max(1, window);
|
|
93
|
+
let offset = scrollOffset;
|
|
94
|
+
if (selLine >= 0) {
|
|
95
|
+
if (selLine < offset) offset = selLine;
|
|
96
|
+
else if (selLine >= offset + win) offset = selLine - win + 1;
|
|
97
|
+
}
|
|
98
|
+
offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - win)));
|
|
99
|
+
const out: string[] = [];
|
|
100
|
+
const end = Math.min(lines.length, offset + win);
|
|
101
|
+
for (let i = offset; i < end; i++) {
|
|
102
|
+
const ln = lines[i]!;
|
|
103
|
+
out.push(i === selLine ? marker(ln) : ` ${ln}`);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pane — a bordered Box hosting a Surface. Renders a title + focus ring on
|
|
110
|
+
* the first line, then the surface's (windowed) lines. This is the v1 single
|
|
111
|
+
* pane chrome lifted into a reusable wrapper; the workspace root instantiates
|
|
112
|
+
* one Pane per pane node.
|
|
113
|
+
*/
|
|
114
|
+
export class Pane implements Component {
|
|
115
|
+
private readonly surface: Surface;
|
|
116
|
+
private readonly theme: PaneTheme;
|
|
117
|
+
private readonly borderFn: (slot: ThemeSlot, text: string) => string;
|
|
118
|
+
/** Current viewport height in rows (for windowing). */
|
|
119
|
+
rows = 24;
|
|
120
|
+
focused = false;
|
|
121
|
+
private cacheKey = "";
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
surface: Surface,
|
|
125
|
+
theme: PaneTheme,
|
|
126
|
+
// Bind through the theme object: a bare `theme.fg` default would detach
|
|
127
|
+
// `this`, so the real pi theme's `fg` (which reads `this.fgColors.get(...)`)
|
|
128
|
+
// would crash with "Cannot read properties of undefined (reading 'get')".
|
|
129
|
+
borderFn: (slot: ThemeSlot, text: string) => string = (slot, text) => theme.fg(slot, text),
|
|
130
|
+
) {
|
|
131
|
+
this.surface = surface;
|
|
132
|
+
this.theme = theme;
|
|
133
|
+
this.borderFn = borderFn;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get surfaceComponent(): Surface {
|
|
137
|
+
return this.surface;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setFocused(focused: boolean): void {
|
|
141
|
+
this.focused = focused;
|
|
142
|
+
this.surface.setFocused(focused);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
invalidate(): void {
|
|
146
|
+
this.cacheKey = "";
|
|
147
|
+
this.surface.invalidate();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
render(width: number): string[] {
|
|
151
|
+
const inner = Math.max(2, width - 2);
|
|
152
|
+
// The surface already windows + selection-marks its own lines (each
|
|
153
|
+
// surface owns its scroll/selection state). The pane only wraps them in a
|
|
154
|
+
// border and PADS to fill its allocated height so the split looks clean —
|
|
155
|
+
// it must NOT re-window (that double-applies the indent/marker and corrupts
|
|
156
|
+
// the layout). Each body line is sliced/padded to `inner` so borders align.
|
|
157
|
+
const surfaceLines = this.surface.render(inner);
|
|
158
|
+
const title = this.surface.title();
|
|
159
|
+
const active = this.focused;
|
|
160
|
+
const borderSlot = active ? "accent" : "dim";
|
|
161
|
+
const marker = active ? "◆ " : " ";
|
|
162
|
+
const titleText = `${marker}${title} `;
|
|
163
|
+
const top = `${this.borderFn(borderSlot, "┌")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┐")}`;
|
|
164
|
+
const titleVis = visibleWidth(titleText);
|
|
165
|
+
const titleLine = `${this.borderFn(borderSlot, "│")}${this.theme.bold(titleText)}${" ".repeat(Math.max(0, inner - titleVis))}${this.borderFn(borderSlot, "│")}`;
|
|
166
|
+
// top + title + bottom = 3 fixed lines; body fills the rest so both panes
|
|
167
|
+
// in a split render the same height.
|
|
168
|
+
const bodyRows = Math.max(0, this.rows - 3);
|
|
169
|
+
const out = [top, titleLine];
|
|
170
|
+
for (let i = 0; i < bodyRows; i++) {
|
|
171
|
+
const ln = surfaceLines[i] ?? "";
|
|
172
|
+
const slice = visibleWidth(ln) > inner ? truncateToWidth(ln, inner) : ln;
|
|
173
|
+
const pad = Math.max(0, inner - visibleWidth(slice));
|
|
174
|
+
out.push(`${this.borderFn(borderSlot, "│")}${slice}${" ".repeat(pad)}${this.borderFn(borderSlot, "│")}`);
|
|
175
|
+
}
|
|
176
|
+
const bottom = `${this.borderFn(borderSlot, "└")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┘")}`;
|
|
177
|
+
out.push(bottom);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type { Component, SurfaceKind };
|
|
@@ -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
|
+
}
|