pi-weave 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
package/src/core/view/health.ts
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* healthModel — staleness + link health, derived exclusively from the
|
|
3
|
-
* GraphModel (weave-workspace §3).
|
|
4
|
-
*
|
|
5
|
-
* Zero new server/core fields: everything here is a projection of nodes,
|
|
6
|
-
* edges, and `model.staleness`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { GraphModel, GraphNode } from "../graph/model";
|
|
10
|
-
import { deriveBacklinks } from "./links";
|
|
11
|
-
import { listLabel } from "./tree";
|
|
12
|
-
import type { SelectableRow } from "./types";
|
|
13
|
-
|
|
14
|
-
export interface HealthRow extends SelectableRow {
|
|
15
|
-
text: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface HealthSection {
|
|
19
|
-
heading: string;
|
|
20
|
-
rows: HealthRow[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface HealthModel {
|
|
24
|
-
sections: HealthSection[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const HEALTH_LIST_CAP = 10;
|
|
28
|
-
|
|
29
|
-
export function healthModel(model: GraphModel): HealthModel {
|
|
30
|
-
const byId = new Map<string, GraphNode>();
|
|
31
|
-
for (const n of model.nodes) byId.set(n.id, n);
|
|
32
|
-
const sections: HealthSection[] = [];
|
|
33
|
-
|
|
34
|
-
// Repository section
|
|
35
|
-
const repo = byId.get("repository");
|
|
36
|
-
if (repo) {
|
|
37
|
-
const rows: HealthRow[] = [];
|
|
38
|
-
const staleness = model.staleness;
|
|
39
|
-
if (staleness) {
|
|
40
|
-
rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
|
|
41
|
-
for (let i = 0; i < staleness.reasons.length; i++) {
|
|
42
|
-
rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
|
|
46
|
-
if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
|
|
47
|
-
let summarized = 0;
|
|
48
|
-
for (const n of model.nodes) {
|
|
49
|
-
if (n.kind !== "module") continue;
|
|
50
|
-
const count = n.detail["summarized files"];
|
|
51
|
-
if (count) summarized += Number(count);
|
|
52
|
-
}
|
|
53
|
-
if (summarized > 0) {
|
|
54
|
-
rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
|
|
55
|
-
}
|
|
56
|
-
sections.push({ heading: "Repository", rows });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Vault section
|
|
60
|
-
const vault = byId.get("vault");
|
|
61
|
-
if (vault) {
|
|
62
|
-
const rows: HealthRow[] = [];
|
|
63
|
-
const noteCount = Number(vault.detail.notes ?? "0");
|
|
64
|
-
rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
|
|
65
|
-
const prov = countProvenance(model.nodes);
|
|
66
|
-
rows.push({
|
|
67
|
-
id: "health:vault:provenance",
|
|
68
|
-
text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
|
|
69
|
-
});
|
|
70
|
-
if (vault.detail.warning) {
|
|
71
|
-
rows.push({ id: "health:vault:warning", text: vault.detail.warning });
|
|
72
|
-
}
|
|
73
|
-
sections.push({ heading: "Vault", rows });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Link health
|
|
77
|
-
const backlinks = deriveBacklinks(model.edges);
|
|
78
|
-
const orphans: GraphNode[] = [];
|
|
79
|
-
const dangling: { node: GraphNode; count: number }[] = [];
|
|
80
|
-
for (const n of model.nodes) {
|
|
81
|
-
if (n.kind !== "note") continue;
|
|
82
|
-
if (!backlinks.has(n.id)) orphans.push(n);
|
|
83
|
-
const dl = n.detail["dangling links"];
|
|
84
|
-
if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
|
|
85
|
-
}
|
|
86
|
-
const degree = new Map<string, number>();
|
|
87
|
-
for (const e of model.edges) {
|
|
88
|
-
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
89
|
-
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
90
|
-
}
|
|
91
|
-
const hubs = [...model.nodes]
|
|
92
|
-
.map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
|
|
93
|
-
.filter((x) => x.d > 0)
|
|
94
|
-
.sort((a, b) => b.d - a.d)
|
|
95
|
-
.slice(0, HEALTH_LIST_CAP);
|
|
96
|
-
|
|
97
|
-
const linkRows: HealthRow[] = [];
|
|
98
|
-
if (orphans.length > 0) {
|
|
99
|
-
linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
|
|
100
|
-
const shown = orphans.slice(0, HEALTH_LIST_CAP);
|
|
101
|
-
for (let i = 0; i < shown.length; i++) {
|
|
102
|
-
linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
|
|
103
|
-
}
|
|
104
|
-
if (orphans.length > HEALTH_LIST_CAP) {
|
|
105
|
-
linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
|
|
109
|
-
}
|
|
110
|
-
if (dangling.length > 0) {
|
|
111
|
-
linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
|
|
112
|
-
const shown = dangling.slice(0, HEALTH_LIST_CAP);
|
|
113
|
-
for (let i = 0; i < shown.length; i++) {
|
|
114
|
-
linkRows.push({
|
|
115
|
-
id: `health:link:dangling:${shown[i]!.node.id}`,
|
|
116
|
-
text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
|
|
117
|
-
target: shown[i]!.node.id,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
if (dangling.length > HEALTH_LIST_CAP) {
|
|
121
|
-
linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
|
|
122
|
-
}
|
|
123
|
-
// How many are *repairable* is not knowable from the graph: resolution
|
|
124
|
-
// needs note bodies and titles, which the model flattens away. Point at
|
|
125
|
-
// the tool that does know rather than re-deriving a worse answer here.
|
|
126
|
-
linkRows.push({ id: "health:link:dangling:repair", text: " repair: weave_note action=links (fix: true to apply)" });
|
|
127
|
-
}
|
|
128
|
-
if (hubs.length > 0) {
|
|
129
|
-
linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
|
|
130
|
-
for (let i = 0; i < hubs.length; i++) {
|
|
131
|
-
linkRows.push({
|
|
132
|
-
id: `health:link:hub:${hubs[i]!.n.id}`,
|
|
133
|
-
text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
|
|
134
|
-
target: hubs[i]!.n.id,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
sections.push({ heading: "Link health", rows: linkRows });
|
|
139
|
-
|
|
140
|
-
return { sections };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export interface ProvenanceCounts {
|
|
144
|
-
total: number;
|
|
145
|
-
human: number;
|
|
146
|
-
agent: number;
|
|
147
|
-
generated: number;
|
|
148
|
-
structural: number;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
|
|
152
|
-
const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
|
|
153
|
-
for (const n of nodes) {
|
|
154
|
-
if (n.provenance === "human") c.human++;
|
|
155
|
-
else if (n.provenance === "agent") c.agent++;
|
|
156
|
-
else if (n.provenance === "generated") c.generated++;
|
|
157
|
-
else c.structural++;
|
|
158
|
-
}
|
|
159
|
-
return c;
|
|
160
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BodyStore — shared note/.okf body cache (weave-view-tui-v2 §6, §9.1).
|
|
3
|
-
*
|
|
4
|
-
* Every pane (e.g. two Detail panes reading the same note) shares one fetch
|
|
5
|
-
* per node id. A body load is kicked off once per id, in-flight loads are
|
|
6
|
-
* deduped, and a refresh busts the cache so the next read re-fetches.
|
|
7
|
-
*
|
|
8
|
-
* The store is harness-free (takes injected loaders + an optional
|
|
9
|
-
* onChange callback).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ViewNote } from "../../../core/graph/current";
|
|
13
|
-
|
|
14
|
-
/** The body loaders a BodyStore is bound to (bound to vault/cwd by run.ts). */
|
|
15
|
-
export interface BodyLoaders {
|
|
16
|
-
loadNote: (slug: string) => Promise<ViewNote | null>;
|
|
17
|
-
loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface BodyStoreOptions {
|
|
21
|
-
loaders: BodyLoaders;
|
|
22
|
-
/** Invoked (no args) when an async load resolves so the owner can re-render. */
|
|
23
|
-
onChange?: () => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* A cache+dedup keyed by node id. `get` never throws and never double-loads.
|
|
28
|
-
*
|
|
29
|
-
* - `null` body = the node exists but has no loadable body (or it loaded to
|
|
30
|
-
* null); the pane renders nothing for it.
|
|
31
|
-
* - `undefined` = not yet requested (the pane shows a placeholder and asks the
|
|
32
|
-
* store to load, mirroring v1's `bodyLinesFor`).
|
|
33
|
-
* - `isLoading(id)` = an in-flight request is outstanding (pane shows "loading").
|
|
34
|
-
*/
|
|
35
|
-
export class BodyStore {
|
|
36
|
-
private readonly loaders: BodyLoaders;
|
|
37
|
-
private readonly onChange: (() => void) | undefined;
|
|
38
|
-
private cache = new Map<string, string | null>();
|
|
39
|
-
private loading = new Set<string>();
|
|
40
|
-
|
|
41
|
-
constructor(opts: BodyStoreOptions) {
|
|
42
|
-
this.loaders = opts.loaders;
|
|
43
|
-
this.onChange = opts.onChange;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** True when the id is mid-load. */
|
|
47
|
-
isLoading(id: string): boolean {
|
|
48
|
-
return this.loading.has(id);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Cached body for the id, or undefined when not yet requested. */
|
|
52
|
-
get(id: string): string | null | undefined {
|
|
53
|
-
return this.cache.get(id);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** True once a body has been requested for the id (cached or failed). */
|
|
57
|
-
has(id: string): boolean {
|
|
58
|
-
return this.cache.has(id);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Kick off a load for `id` if not already requested/in-flight. Returns true
|
|
63
|
-
* when a load was started (so the caller can show a placeholder). The load
|
|
64
|
-
* uses `kind` (note/file) to pick the loader and `ref` (slug/rel path).
|
|
65
|
-
*/
|
|
66
|
-
load(id: string, kind: "note" | "file", ref: string | undefined): boolean {
|
|
67
|
-
if (ref === undefined) return false;
|
|
68
|
-
if (this.cache.has(id) || this.loading.has(id)) return false;
|
|
69
|
-
this.loading.add(id);
|
|
70
|
-
if (kind === "note") {
|
|
71
|
-
void this.loaders
|
|
72
|
-
.loadNote(ref)
|
|
73
|
-
.then((note) => this.finish(id, note?.body ?? null))
|
|
74
|
-
.catch(() => this.finish(id, null));
|
|
75
|
-
} else {
|
|
76
|
-
void this.loaders
|
|
77
|
-
.loadOkf(ref)
|
|
78
|
-
.then((file) => this.finish(id, file?.body ?? null))
|
|
79
|
-
.catch(() => this.finish(id, null));
|
|
80
|
-
}
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Drop all cached bodies and in-flight markers (the `r` refresh). */
|
|
85
|
-
clear(): void {
|
|
86
|
-
this.cache.clear();
|
|
87
|
-
this.loading.clear();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private finish(id: string, body: string | null): void {
|
|
91
|
-
this.cache.set(id, body);
|
|
92
|
-
this.loading.delete(id);
|
|
93
|
-
this.onChange?.();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import type { ThemeSlot } from "./theme";
|
|
3
|
-
|
|
4
|
-
/** The TUI uses one terminal-safe mark. */
|
|
5
|
-
export const MARK_GLYPH = "◈";
|
|
6
|
-
export const WORDMARK = "weave view";
|
|
7
|
-
|
|
8
|
-
export function renderMark(
|
|
9
|
-
theme: { fg: (slot: ThemeSlot, text: string) => string },
|
|
10
|
-
width: number,
|
|
11
|
-
): string {
|
|
12
|
-
const mark = theme.fg("accent", MARK_GLYPH);
|
|
13
|
-
return visibleWidth(mark) <= width ? mark : "";
|
|
14
|
-
}
|
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TUI-specific view-model for the weave-view explorer (weave-view-tui-design
|
|
3
|
-
* §5, §10.1).
|
|
4
|
-
*
|
|
5
|
-
* The portable projections (`treeRows`, `detailModel`, `focusModel`,
|
|
6
|
-
* `healthModel`, …) now live in `src/core/view` so the browser workspace can
|
|
7
|
-
* share them (weave-workspace §3). What stays here is what is genuinely about
|
|
8
|
-
* a terminal: the explorer's `ExplorerState`/`Action`/`reduce` state machine,
|
|
9
|
-
* terminal-escape sanitising, and the theme glue.
|
|
10
|
-
*
|
|
11
|
-
* Everything moved is re-exported below, so TUI files keep importing from
|
|
12
|
-
* `./model`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { NoteSource } from "../../../core/types";
|
|
16
|
-
import type { SelectableRow, TreeRow } from "../../../core/view";
|
|
17
|
-
import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// sanitizeTerminalText — the TUI's XSS analog (design §9.3)
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
/** Strip terminal-escape/control sequences from disk/user content before
|
|
24
|
-
* styling or handing to Markdown: ESC (0x1b), CSI (0x9b), BEL (0x07), and
|
|
25
|
-
* other C0 controls except newline (0x0a) and tab (0x09). */
|
|
26
|
-
export function sanitizeTerminalText(s: string): string {
|
|
27
|
-
// eslint-disable-next-line no-control-regex
|
|
28
|
-
return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// ExplorerState + reduce — key→state transitions (design §6, §10.1)
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
export type Surface = "tree" | "detail" | "focus" | "health";
|
|
36
|
-
|
|
37
|
-
export interface ExplorerState {
|
|
38
|
-
surface: Surface;
|
|
39
|
-
/** Inline search sub-mode (only meaningful on the tree surface). */
|
|
40
|
-
searching: boolean;
|
|
41
|
-
/** Selected node id (source of truth; the cursor index is derived). */
|
|
42
|
-
selectedId: string | null;
|
|
43
|
-
/** Node pinned for focus mode. */
|
|
44
|
-
focusId: string | null;
|
|
45
|
-
/** Node open in detail. */
|
|
46
|
-
detailId: string | null;
|
|
47
|
-
/** Tree expansion (when no filter is active). */
|
|
48
|
-
expanded: Set<string>;
|
|
49
|
-
showInternals: boolean;
|
|
50
|
-
provFilter: NoteSource | null;
|
|
51
|
-
query: string;
|
|
52
|
-
helpOpen: boolean;
|
|
53
|
-
refreshing: boolean;
|
|
54
|
-
/** Bumped on every mutation so the render cache can key off it. */
|
|
55
|
-
version: number;
|
|
56
|
-
/** Scroll offset (top visible row index) for the current surface. */
|
|
57
|
-
scrollOffset: number;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function initialState(roots: readonly string[]): ExplorerState {
|
|
61
|
-
const expanded = new Set<string>();
|
|
62
|
-
for (const r of roots) expanded.add(r);
|
|
63
|
-
const selectedId = roots[0] ?? null;
|
|
64
|
-
return {
|
|
65
|
-
surface: "tree",
|
|
66
|
-
searching: false,
|
|
67
|
-
selectedId,
|
|
68
|
-
focusId: null,
|
|
69
|
-
detailId: null,
|
|
70
|
-
expanded,
|
|
71
|
-
showInternals: false,
|
|
72
|
-
provFilter: null,
|
|
73
|
-
query: "",
|
|
74
|
-
helpOpen: false,
|
|
75
|
-
refreshing: false,
|
|
76
|
-
version: 0,
|
|
77
|
-
scrollOffset: 0,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export type Action =
|
|
82
|
-
| { type: "up" }
|
|
83
|
-
| { type: "down" }
|
|
84
|
-
| { type: "pageUp" }
|
|
85
|
-
| { type: "pageDown" }
|
|
86
|
-
| { type: "home" }
|
|
87
|
-
| { type: "end" }
|
|
88
|
-
| { type: "left" }
|
|
89
|
-
| { type: "right" }
|
|
90
|
-
| { type: "enter" }
|
|
91
|
-
| { type: "searchStart" }
|
|
92
|
-
| { type: "searchChar"; ch: string }
|
|
93
|
-
| { type: "searchBackspace" }
|
|
94
|
-
| { type: "searchCommit" }
|
|
95
|
-
| { type: "cycleProvenance" }
|
|
96
|
-
| { type: "toggleInternals" }
|
|
97
|
-
| { type: "focus" }
|
|
98
|
-
| { type: "focusExit" }
|
|
99
|
-
| { type: "surfaceTree" }
|
|
100
|
-
| { type: "surfaceHealth" }
|
|
101
|
-
| { type: "refresh" }
|
|
102
|
-
| { type: "refreshDone" }
|
|
103
|
-
| { type: "toggleHelp" }
|
|
104
|
-
| { type: "quit" }
|
|
105
|
-
| { type: "esc" };
|
|
106
|
-
|
|
107
|
-
/** Context reduce needs for movement: the selectable rows of the current
|
|
108
|
-
* surface (in display order) and the viewport window size. */
|
|
109
|
-
export interface ReduceCtx {
|
|
110
|
-
rows: readonly SelectableRow[];
|
|
111
|
-
window: number;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function bump(s: ExplorerState): ExplorerState {
|
|
115
|
-
return { ...s, version: s.version + 1 };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function clampIndex(idx: number, len: number): number {
|
|
119
|
-
if (len === 0) return -1;
|
|
120
|
-
if (idx >= len) return len - 1;
|
|
121
|
-
return idx;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function scrollForSelection(idx: number, window: number, prev: number): number {
|
|
125
|
-
if (window <= 0) return 0;
|
|
126
|
-
// keep selection visible: offset <= idx < offset + window
|
|
127
|
-
if (idx < prev) return idx;
|
|
128
|
-
if (idx >= prev + window) return idx - window + 1;
|
|
129
|
-
return prev;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function provenanceCycleNext(current: NoteSource | null): NoteSource | null {
|
|
133
|
-
const i = PROVENANCE_CYCLE.indexOf(current);
|
|
134
|
-
const next = PROVENANCE_CYCLE[(i + 1) % PROVENANCE_CYCLE.length];
|
|
135
|
-
return next ?? null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Apply an action to the explorer state. Pure: returns a new state.
|
|
140
|
-
*
|
|
141
|
-
* Movement uses `ctx.rows` (the current surface's selectable rows) and
|
|
142
|
-
* `ctx.window` (viewport height) to clamp the cursor and adjust scroll.
|
|
143
|
-
* The component builds `ctx.rows` from the surface's model function.
|
|
144
|
-
*/
|
|
145
|
-
export function reduce(state: ExplorerState, action: Action, ctx: ReduceCtx = { rows: [], window: 24 }): ExplorerState {
|
|
146
|
-
const rows = ctx.rows;
|
|
147
|
-
const len = rows.length;
|
|
148
|
-
const currentIdx = state.selectedId ? rows.findIndex((r) => r.id === state.selectedId || r.target === state.selectedId) : -1;
|
|
149
|
-
|
|
150
|
-
switch (action.type) {
|
|
151
|
-
case "up":
|
|
152
|
-
case "down":
|
|
153
|
-
case "pageUp":
|
|
154
|
-
case "pageDown":
|
|
155
|
-
case "home":
|
|
156
|
-
case "end": {
|
|
157
|
-
if (len === 0) return state;
|
|
158
|
-
let nextIdx: number;
|
|
159
|
-
if (action.type === "up") nextIdx = currentIdx <= 0 ? 0 : currentIdx - 1;
|
|
160
|
-
else if (action.type === "down") nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, len - 1);
|
|
161
|
-
else if (action.type === "pageUp") nextIdx = currentIdx < 0 ? 0 : Math.max(0, currentIdx - ctx.window);
|
|
162
|
-
else if (action.type === "pageDown") nextIdx = currentIdx < 0 ? 0 : Math.min(len - 1, currentIdx + ctx.window);
|
|
163
|
-
else if (action.type === "home") nextIdx = 0;
|
|
164
|
-
else nextIdx = len - 1; // end
|
|
165
|
-
nextIdx = clampIndex(nextIdx, len);
|
|
166
|
-
const row = rows[nextIdx];
|
|
167
|
-
if (!row) return state;
|
|
168
|
-
const selectedId = row.target ?? row.id;
|
|
169
|
-
const scrollOffset = scrollForSelection(nextIdx, ctx.window, state.scrollOffset);
|
|
170
|
-
return bump({ ...state, selectedId, scrollOffset });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
case "left":
|
|
174
|
-
case "right": {
|
|
175
|
-
if (state.surface !== "tree") return state;
|
|
176
|
-
if (!state.selectedId) return state;
|
|
177
|
-
// operate on the tree: find the row for selectedId
|
|
178
|
-
const treeRowIdx = rows.findIndex((r) => r.id === state.selectedId);
|
|
179
|
-
const treeRow = treeRowIdx >= 0 ? (rows[treeRowIdx] as TreeRow | undefined) : undefined;
|
|
180
|
-
const isExpanded = state.expanded.has(state.selectedId);
|
|
181
|
-
if (action.type === "right") {
|
|
182
|
-
if (treeRow && treeRow.hasKids && !isExpanded) {
|
|
183
|
-
const expanded = new Set(state.expanded).add(state.selectedId);
|
|
184
|
-
return bump({ ...state, expanded });
|
|
185
|
-
}
|
|
186
|
-
if (treeRow && isExpanded) {
|
|
187
|
-
// move to first child
|
|
188
|
-
const child = rows[treeRowIdx + 1];
|
|
189
|
-
if (child) return bump({ ...state, selectedId: child.id, scrollOffset: scrollForSelection(treeRowIdx + 1, ctx.window, state.scrollOffset) });
|
|
190
|
-
}
|
|
191
|
-
return state;
|
|
192
|
-
}
|
|
193
|
-
// left: collapse, or jump to parent
|
|
194
|
-
if (treeRow && isExpanded) {
|
|
195
|
-
const expanded = new Set(state.expanded);
|
|
196
|
-
expanded.delete(state.selectedId);
|
|
197
|
-
return bump({ ...state, expanded });
|
|
198
|
-
}
|
|
199
|
-
// jump to parent: the nearest preceding row with smaller depth
|
|
200
|
-
for (let i = treeRowIdx - 1; i >= 0; i--) {
|
|
201
|
-
const r = rows[i] as TreeRow | undefined;
|
|
202
|
-
if (r && r.depth < (treeRow?.depth ?? 0)) {
|
|
203
|
-
return bump({ ...state, selectedId: r.id, scrollOffset: scrollForSelection(i, ctx.window, state.scrollOffset) });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return state;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
case "enter": {
|
|
210
|
-
if (state.searching) {
|
|
211
|
-
// commit search: keep filter, exit search mode
|
|
212
|
-
return bump({ ...state, searching: false, scrollOffset: 0 });
|
|
213
|
-
}
|
|
214
|
-
if (!state.selectedId) return state;
|
|
215
|
-
const row = currentIdx >= 0 ? rows[currentIdx] : undefined;
|
|
216
|
-
if (state.surface === "tree") {
|
|
217
|
-
return bump({ ...state, surface: "detail", detailId: state.selectedId, scrollOffset: 0 });
|
|
218
|
-
}
|
|
219
|
-
if (state.surface === "detail") {
|
|
220
|
-
// jump to the selected link/backlink target
|
|
221
|
-
if (row && row.target) {
|
|
222
|
-
return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
223
|
-
}
|
|
224
|
-
return state;
|
|
225
|
-
}
|
|
226
|
-
if (state.surface === "focus") {
|
|
227
|
-
// re-center on the selected neighbor
|
|
228
|
-
if (row && row.target) {
|
|
229
|
-
return bump({ ...state, focusId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
230
|
-
}
|
|
231
|
-
return state;
|
|
232
|
-
}
|
|
233
|
-
if (state.surface === "health") {
|
|
234
|
-
if (row && row.target) {
|
|
235
|
-
return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
236
|
-
}
|
|
237
|
-
return state;
|
|
238
|
-
}
|
|
239
|
-
return state;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
case "searchStart": {
|
|
243
|
-
if (state.searching) return state;
|
|
244
|
-
if (state.surface === "detail") return state;
|
|
245
|
-
return bump({ ...state, searching: true });
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
case "searchChar": {
|
|
249
|
-
if (!state.searching) return state;
|
|
250
|
-
const q = (state.query + action.ch).slice(0, MAX_FILTER_LEN);
|
|
251
|
-
return bump({ ...state, query: q, scrollOffset: 0 });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
case "searchBackspace": {
|
|
255
|
-
if (!state.searching) return state;
|
|
256
|
-
return bump({ ...state, query: state.query.slice(0, -1), scrollOffset: 0 });
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
case "searchCommit": {
|
|
260
|
-
if (!state.searching) return state;
|
|
261
|
-
return bump({ ...state, searching: false, scrollOffset: 0 });
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
case "esc": {
|
|
265
|
-
// precedence: search > surface-exit > quit
|
|
266
|
-
if (state.searching) {
|
|
267
|
-
return bump({ ...state, searching: false, query: "", scrollOffset: 0 });
|
|
268
|
-
}
|
|
269
|
-
if (state.surface === "detail") {
|
|
270
|
-
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
271
|
-
}
|
|
272
|
-
if (state.surface === "focus") {
|
|
273
|
-
return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
|
|
274
|
-
}
|
|
275
|
-
if (state.surface === "health") {
|
|
276
|
-
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
277
|
-
}
|
|
278
|
-
// tree → quit (component resolves done(null))
|
|
279
|
-
return bump({ ...state });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
case "quit": {
|
|
283
|
-
return bump({ ...state });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
case "cycleProvenance": {
|
|
287
|
-
if (state.searching) return state;
|
|
288
|
-
return bump({ ...state, provFilter: provenanceCycleNext(state.provFilter), scrollOffset: 0 });
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
case "toggleInternals": {
|
|
292
|
-
if (state.searching) return state;
|
|
293
|
-
return bump({ ...state, showInternals: !state.showInternals, scrollOffset: 0 });
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
case "focus": {
|
|
297
|
-
if (state.searching) return state;
|
|
298
|
-
if (!state.selectedId) return state;
|
|
299
|
-
return bump({ ...state, surface: "focus", focusId: state.selectedId, scrollOffset: 0 });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
case "focusExit": {
|
|
303
|
-
return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
case "surfaceTree": {
|
|
307
|
-
if (state.searching) return state;
|
|
308
|
-
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
case "surfaceHealth": {
|
|
312
|
-
if (state.searching) return state;
|
|
313
|
-
return bump({ ...state, surface: "health", scrollOffset: 0 });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
case "refresh": {
|
|
317
|
-
return bump({ ...state, refreshing: true });
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
case "refreshDone": {
|
|
321
|
-
return bump({ ...state, refreshing: false });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
case "toggleHelp": {
|
|
325
|
-
if (state.searching) return state;
|
|
326
|
-
return bump({ ...state, helpOpen: !state.helpOpen });
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// Exhaustive over Action; unreachable for valid inputs.
|
|
330
|
-
return state;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Merge explorer state across a refresh (design §6): keep the expanded set,
|
|
335
|
-
* filter, surface, and selected node id. A selected/expanded id that no
|
|
336
|
-
* longer exists drops out; selection falls back to the first root.
|
|
337
|
-
*/
|
|
338
|
-
export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly string[]): ExplorerState {
|
|
339
|
-
let selectedId = prev.selectedId;
|
|
340
|
-
if (selectedId !== null && !nextRoots.includes(selectedId)) {
|
|
341
|
-
// selectedId may be a non-root; the component re-resolves it against the
|
|
342
|
-
// new tree rows. Roots fallback only when it is null.
|
|
343
|
-
}
|
|
344
|
-
if (selectedId === null) selectedId = nextRoots[0] ?? null;
|
|
345
|
-
return {
|
|
346
|
-
...prev,
|
|
347
|
-
refreshing: false,
|
|
348
|
-
selectedId,
|
|
349
|
-
version: prev.version + 1,
|
|
350
|
-
scrollOffset: 0,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ---------------------------------------------------------------------------
|
|
355
|
-
// Re-exports — the portable view-models, kept importable from `./model` so no
|
|
356
|
-
// TUI file needs to know they moved to src/core/view.
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
|
|
359
|
-
export {
|
|
360
|
-
countProvenance,
|
|
361
|
-
degreeOf,
|
|
362
|
-
deriveBacklinks,
|
|
363
|
-
detailModel,
|
|
364
|
-
focusModel,
|
|
365
|
-
focusNeighborhood,
|
|
366
|
-
formatTreeMeta,
|
|
367
|
-
graphRoots,
|
|
368
|
-
healthModel,
|
|
369
|
-
listLabel,
|
|
370
|
-
relTime,
|
|
371
|
-
treeEmptyHint,
|
|
372
|
-
treeRows,
|
|
373
|
-
type DetailLinkRow,
|
|
374
|
-
type DetailMetaRow,
|
|
375
|
-
type DetailModel,
|
|
376
|
-
type FocusGroup,
|
|
377
|
-
type FocusModel,
|
|
378
|
-
type FocusRow,
|
|
379
|
-
type HealthModel,
|
|
380
|
-
type HealthRow,
|
|
381
|
-
type HealthSection,
|
|
382
|
-
type ProvenanceCounts,
|
|
383
|
-
type SelectableRow,
|
|
384
|
-
type TreeMeta,
|
|
385
|
-
type TreeRow,
|
|
386
|
-
type TreeState,
|
|
387
|
-
} from "../../../core/view";
|
|
388
|
-
|
|
389
|
-
// Re-export theme helpers some tests reach for.
|
|
390
|
-
export { provenanceStyle, type ProvenanceStyle };
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compatibility re-export.
|
|
3
|
-
*
|
|
4
|
-
* The implementation moved to `src/core/openInEditor.ts` (weave-workspace
|
|
5
|
-
* §5.3): the browser workspace's `POST /api/open` needs it and
|
|
6
|
-
* `src/web/server/**` may not import `src/pi`. Keeping this module as a
|
|
7
|
-
* one-line re-export means `run.ts` and every TUI test kept working without
|
|
8
|
-
* an edit; there is nothing TUI-specific left to live here.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export { openNoteCommand, openNoteInEditor } from "../../../core/openInEditor";
|