pi-weave 0.1.7 → 0.1.8
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 +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link and tag derivations over vault/graph data (weave-workspace §3, §4.3).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { GraphEdge } from "../graph/model";
|
|
6
|
+
import type { NoteMeta } from "../types";
|
|
7
|
+
|
|
8
|
+
/** Map each node id → its incoming `links-to` sources (backlinks). */
|
|
9
|
+
export function deriveBacklinks(edges: readonly GraphEdge[]): Map<string, string[]> {
|
|
10
|
+
const out = new Map<string, string[]>();
|
|
11
|
+
for (const e of edges) {
|
|
12
|
+
if (e.kind !== "links-to") continue;
|
|
13
|
+
const list = out.get(e.target);
|
|
14
|
+
if (list) list.push(e.source);
|
|
15
|
+
else out.set(e.target, [e.source]);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One tag and every note carrying it (weave-workspace §4.3).
|
|
22
|
+
*
|
|
23
|
+
* `slugs` rather than node ids: `note:` is a graph-internal prefix, and every
|
|
24
|
+
* consumer of this index — the note column's tag chips, `GraphPayload.tags` —
|
|
25
|
+
* addresses notes by slug.
|
|
26
|
+
*/
|
|
27
|
+
export interface TagIndex {
|
|
28
|
+
tag: string;
|
|
29
|
+
slugs: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The minimum a note has to expose to be tag-indexed: its identity and its
|
|
34
|
+
* tags.
|
|
35
|
+
*
|
|
36
|
+
* Declared structurally instead of taking `NoteSummary` outright so the
|
|
37
|
+
* function accepts a `Note` (which has a `body` and no `bodyLength`) just as
|
|
38
|
+
* happily as a `NoteSummary`. §4.3 specifies `NoteSummary[]`; this is that
|
|
39
|
+
* signature widened to its actual requirement rather than narrowed past it,
|
|
40
|
+
* and `NoteSummary` still satisfies it exactly.
|
|
41
|
+
*/
|
|
42
|
+
export type TaggedNote = Pick<NoteMeta, "tags"> & { slug: string };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Invert `note → tags` into `tag → notes` (weave-workspace §4.3).
|
|
46
|
+
*
|
|
47
|
+
* ## Why this takes notes and not a `GraphModel`
|
|
48
|
+
*
|
|
49
|
+
* The obvious alternative is deriving from the graph, since every other
|
|
50
|
+
* function in this directory does. It is the wrong source here, and the
|
|
51
|
+
* reason is worth stating because "derive everything from the graph" is
|
|
52
|
+
* otherwise a good rule.
|
|
53
|
+
*
|
|
54
|
+
* `buildGraph` flattens tags into `detail.tags`, a comma-joined **display**
|
|
55
|
+
* string (`build.ts`: `note.tags.join(", ")`). Recovering an array from it
|
|
56
|
+
* means splitting on `", "` — which is re-parsing a display string into
|
|
57
|
+
* structure, precisely what §4.2/§4.3 forbid `detail` from being used for,
|
|
58
|
+
* and it is lossy besides: a tag containing a comma round-trips wrong, and
|
|
59
|
+
* the tag is simply absent when a note has none. The alternative is to make
|
|
60
|
+
* `GraphNode` carry a real `tags: string[]` alongside the display string,
|
|
61
|
+
* which puts the same fact on the node twice and grows the wire model for
|
|
62
|
+
* every note whether or not anything reads it.
|
|
63
|
+
*
|
|
64
|
+
* Notes are the upstream source of truth. Both callers already hold them —
|
|
65
|
+
* `buildCurrentGraph` and `WorkspaceCache` each read the vault before they
|
|
66
|
+
* build a graph — so deriving here costs no extra I/O and keeps one
|
|
67
|
+
* representation of a tag: the array on the note. The graph's display string
|
|
68
|
+
* stays a projection of that, never an input to it.
|
|
69
|
+
*
|
|
70
|
+
* ## Ordering
|
|
71
|
+
*
|
|
72
|
+
* Count descending, then tag ascending, per §4.3 — the popular tags surface
|
|
73
|
+
* first and ties are alphabetical rather than insertion-ordered. Each `slugs`
|
|
74
|
+
* list is sorted ascending too, so the whole structure is a pure function of
|
|
75
|
+
* the tag/slug *set*: two vaults with the same tags in a different note order
|
|
76
|
+
* produce byte-identical JSON, which is what lets this ride the ETag.
|
|
77
|
+
*
|
|
78
|
+
* The tiebreak is **codepoint** order, not `localeCompare`, which is the one
|
|
79
|
+
* place this file departs from the habit elsewhere in core. `localeCompare`
|
|
80
|
+
* reads the host's default locale and the runtime's ICU build — a Node
|
|
81
|
+
* compiled with small-icu can order `"Arch"` against `"arch"` differently
|
|
82
|
+
* from one with full-icu. Everywhere else that is a cosmetic difference in a
|
|
83
|
+
* list a human is about to read; here the output is hashed into a cache key
|
|
84
|
+
* (§5.3), so an ordering that varies by machine is a correctness bug rather
|
|
85
|
+
* than a preference. Codepoint order is total, locale-free and matches the
|
|
86
|
+
* bare `.sort()` used for `slugs` just below.
|
|
87
|
+
*
|
|
88
|
+
* Duplicate tags within one note collapse (front matter is hand-editable and
|
|
89
|
+
* `tags: [a, a]` is a typo, not two memberships).
|
|
90
|
+
*/
|
|
91
|
+
export function deriveTagIndex(notes: readonly TaggedNote[]): TagIndex[] {
|
|
92
|
+
const bySlug = new Map<string, Set<string>>();
|
|
93
|
+
for (const note of notes) {
|
|
94
|
+
for (const tag of note.tags) {
|
|
95
|
+
const slugs = bySlug.get(tag);
|
|
96
|
+
if (slugs) slugs.add(note.slug);
|
|
97
|
+
else bySlug.set(tag, new Set([note.slug]));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...bySlug]
|
|
101
|
+
.map(([tag, slugs]): TagIndex => ({ tag, slugs: [...slugs].sort() }))
|
|
102
|
+
// Tags come from a Map's keys, so no two are equal and the comparator
|
|
103
|
+
// never needs to return 0 for the tiebreak — `< ? -1 : 1` is total here.
|
|
104
|
+
.sort((a, b) => b.slugs.length - a.slugs.length || (a.tag < b.tag ? -1 : 1));
|
|
105
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time formatting for the portable view-models (weave-workspace §3).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TreeMeta } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Relative human time, mirroring the page's `relTime`. `now` is epoch ms. */
|
|
8
|
+
export function relTime(iso: string | undefined, now: number): string {
|
|
9
|
+
if (!iso) return "";
|
|
10
|
+
const t = Date.parse(iso);
|
|
11
|
+
if (Number.isNaN(t)) return "";
|
|
12
|
+
const s = Math.max(0, Math.floor((now - t) / 1000));
|
|
13
|
+
if (s < 60) return "just now";
|
|
14
|
+
const m = Math.floor(s / 60);
|
|
15
|
+
if (m < 60) return `${m}m ago`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
if (h < 24) return `${h}h ago`;
|
|
18
|
+
const d = Math.floor(h / 24);
|
|
19
|
+
if (d < 30) return `${d}d ago`;
|
|
20
|
+
const mo = Math.floor(d / 30);
|
|
21
|
+
if (mo < 12) return `${mo}mo ago`;
|
|
22
|
+
return `${Math.floor(mo / 12)}y ago`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Short-sha length used when abbreviating a commit for display. */
|
|
26
|
+
const SHORT_SHA_LEN = 7;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render a `TreeMeta` as a single plain-text annotation.
|
|
30
|
+
*
|
|
31
|
+
* This reproduces the strings the TUI printed when `TreeRow.meta` was itself a
|
|
32
|
+
* string, so terminal output is unchanged by the structuring. Richer renderers
|
|
33
|
+
* (the browser workspace) should switch on the union instead of calling this.
|
|
34
|
+
*/
|
|
35
|
+
export function formatTreeMeta(meta: TreeMeta, now: number): string {
|
|
36
|
+
if (meta === null) return "";
|
|
37
|
+
switch (meta.kind) {
|
|
38
|
+
case "relTime":
|
|
39
|
+
return relTime(meta.iso, now);
|
|
40
|
+
case "count":
|
|
41
|
+
return meta.phrasing === "attribute" ? `${meta.unit}=${meta.n}` : `${meta.n} ${meta.unit}`;
|
|
42
|
+
case "commit":
|
|
43
|
+
return meta.sha.slice(0, SHORT_SHA_LEN);
|
|
44
|
+
case "text":
|
|
45
|
+
return meta.text;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* treeRows — the portable Explore/tree view-model (weave-workspace §3).
|
|
3
|
+
*
|
|
4
|
+
* Ported 1:1 from the retired browser page's `listTree`. Pure: it takes a
|
|
5
|
+
* `GraphModel` plus a `TreeState` and returns rows. No renderer concepts —
|
|
6
|
+
* no widths, no truncation, no escape codes. Both the TUI and the browser
|
|
7
|
+
* workspace build their tree column from this.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { GraphModel, GraphNode, NodeKind } from "../graph/model";
|
|
11
|
+
import type { NoteSource } from "../types";
|
|
12
|
+
import type { SelectableRow, TreeMeta } from "./types";
|
|
13
|
+
|
|
14
|
+
/** One row in the Explore tree. */
|
|
15
|
+
export interface TreeRow extends SelectableRow {
|
|
16
|
+
depth: number;
|
|
17
|
+
hasKids: boolean;
|
|
18
|
+
expanded: boolean;
|
|
19
|
+
label: string;
|
|
20
|
+
kind: NodeKind;
|
|
21
|
+
provenance: NoteSource | null;
|
|
22
|
+
/** Structured trailing annotation; renderers format it (see formatTreeMeta). */
|
|
23
|
+
meta: TreeMeta;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TreeState {
|
|
27
|
+
/** Expanded node ids (when no filter is active). */
|
|
28
|
+
expanded: ReadonlySet<string>;
|
|
29
|
+
/** Reveal repo plumbing (gitState/external/package/entryPoint). */
|
|
30
|
+
showInternals: boolean;
|
|
31
|
+
/** Provenance filter (null = all). */
|
|
32
|
+
provFilter: NoteSource | null;
|
|
33
|
+
/** Substring filter on node labels (case-insensitive). */
|
|
34
|
+
query: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Disambiguate labels that would otherwise read as the same entry twice
|
|
38
|
+
* (a remote URL whose tail matches the repo name, an npm package named
|
|
39
|
+
* after the repo). Mirrors the page's `listLabel`. */
|
|
40
|
+
export function listLabel(node: GraphNode): string {
|
|
41
|
+
if (node.kind === "external" && node.detail.url) {
|
|
42
|
+
const u = node.detail.url
|
|
43
|
+
.replace(/^[a-z]+:\/\//, "")
|
|
44
|
+
.replace(/^[^@\/]+@/, "")
|
|
45
|
+
.replace(/\.git$/, "")
|
|
46
|
+
.replace(/\/+$/, "");
|
|
47
|
+
if (u) return u;
|
|
48
|
+
}
|
|
49
|
+
if (node.kind === "package" && node.detail.manifest) {
|
|
50
|
+
return `${node.label} (${node.detail.manifest})`;
|
|
51
|
+
}
|
|
52
|
+
return node.label;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the contains/anchored-at nesting + entry-point-to-module map. */
|
|
56
|
+
interface TreeIndex {
|
|
57
|
+
byId: Map<string, GraphNode>;
|
|
58
|
+
/** strict contains (used for entry-point → module placement). */
|
|
59
|
+
contains: Map<string, string[]>;
|
|
60
|
+
/** contains + anchored-at (the nesting hierarchy). */
|
|
61
|
+
tree: Map<string, string[]>;
|
|
62
|
+
/** node id → true when it has an incoming contains/anchored-at edge. */
|
|
63
|
+
incoming: Set<string>;
|
|
64
|
+
/** module id → entry-point ids nested under it (by path prefix). */
|
|
65
|
+
moduleEntries: Map<string, string[]>;
|
|
66
|
+
/** roots (nodes with no incoming edge), in node-input order. */
|
|
67
|
+
roots: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function indexTree(model: GraphModel): TreeIndex {
|
|
71
|
+
const byId = new Map<string, GraphNode>();
|
|
72
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
73
|
+
|
|
74
|
+
const contains = new Map<string, string[]>();
|
|
75
|
+
const tree = new Map<string, string[]>();
|
|
76
|
+
const incoming = new Set<string>();
|
|
77
|
+
for (const e of model.edges) {
|
|
78
|
+
if (e.kind === "contains") {
|
|
79
|
+
const list = contains.get(e.source);
|
|
80
|
+
if (list) list.push(e.target);
|
|
81
|
+
else contains.set(e.source, [e.target]);
|
|
82
|
+
}
|
|
83
|
+
if (e.kind === "contains" || e.kind === "anchored-at") {
|
|
84
|
+
const list = tree.get(e.source);
|
|
85
|
+
if (list) list.push(e.target);
|
|
86
|
+
else tree.set(e.source, [e.target]);
|
|
87
|
+
incoming.add(e.target);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Entry points nest under the module whose path is the entry's directory prefix.
|
|
92
|
+
// The caller only invokes this for entryPoint nodes, so the path guard suffices.
|
|
93
|
+
const moduleFor = (entry: GraphNode): string | null => {
|
|
94
|
+
if (!entry.detail.path) return null;
|
|
95
|
+
const p = entry.detail.path;
|
|
96
|
+
let best: string | null = null;
|
|
97
|
+
let bestLen = 0;
|
|
98
|
+
const repoKids = contains.get("repository") ?? [];
|
|
99
|
+
for (const cid of repoKids) {
|
|
100
|
+
const c = byId.get(cid);
|
|
101
|
+
if (c && c.kind === "module" && c.detail.path) {
|
|
102
|
+
const mp = c.detail.path;
|
|
103
|
+
if (p.startsWith(mp + "/") && mp.length > bestLen) {
|
|
104
|
+
best = cid;
|
|
105
|
+
bestLen = mp.length;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const moduleEntries = new Map<string, string[]>();
|
|
113
|
+
for (const n of model.nodes) {
|
|
114
|
+
if (n.kind !== "entryPoint") continue;
|
|
115
|
+
const m = moduleFor(n);
|
|
116
|
+
if (m) {
|
|
117
|
+
const list = moduleEntries.get(m);
|
|
118
|
+
if (list) list.push(n.id);
|
|
119
|
+
else moduleEntries.set(m, [n.id]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const roots = model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
|
|
124
|
+
return { byId, contains, tree, incoming, moduleEntries, roots };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The roots of a graph (nodes with no incoming contains/anchored-at edge). */
|
|
128
|
+
export function graphRoots(model: GraphModel): string[] {
|
|
129
|
+
const incoming = new Set<string>();
|
|
130
|
+
for (const e of model.edges) {
|
|
131
|
+
if (e.kind === "contains" || e.kind === "anchored-at") incoming.add(e.target);
|
|
132
|
+
}
|
|
133
|
+
return model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Knowledge-first default: hide repo plumbing unless showInternals. */
|
|
137
|
+
function hiddenInternalKind(kind: NodeKind): boolean {
|
|
138
|
+
return kind === "gitState" || kind === "external" || kind === "package" || kind === "entryPoint";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sortKids(ids: string[], byId: Map<string, GraphNode>): string[] {
|
|
142
|
+
return ids
|
|
143
|
+
.slice()
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
const la = listLabel(byId.get(a)!);
|
|
146
|
+
const lb = listLabel(byId.get(b)!);
|
|
147
|
+
return la.localeCompare(lb);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A `count` meta when the detail value is numeric, else a verbatim `text`
|
|
152
|
+
* meta — so a hand-built or corrupt graph still displays what it holds. */
|
|
153
|
+
function countMeta(raw: string, unit: string, phrasing: "attribute" | "prose"): TreeMeta {
|
|
154
|
+
const n = Number(raw);
|
|
155
|
+
if (!Number.isFinite(n)) {
|
|
156
|
+
return { kind: "text", text: phrasing === "attribute" ? `${unit}=${raw}` : `${raw} ${unit}` };
|
|
157
|
+
}
|
|
158
|
+
return { kind: "count", n, unit, phrasing };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function metaFor(node: GraphNode): TreeMeta {
|
|
162
|
+
switch (node.kind) {
|
|
163
|
+
case "note":
|
|
164
|
+
return node.detail.updated ? { kind: "relTime", iso: node.detail.updated } : null;
|
|
165
|
+
case "module":
|
|
166
|
+
return node.detail.files ? countMeta(node.detail.files, "files", "attribute") : null;
|
|
167
|
+
case "gitState":
|
|
168
|
+
return node.detail.commit ? { kind: "commit", sha: node.detail.commit } : null;
|
|
169
|
+
case "repository":
|
|
170
|
+
return node.detail.files ? countMeta(node.detail.files, "files", "prose") : null;
|
|
171
|
+
case "package":
|
|
172
|
+
return node.detail.kind ? { kind: "text", text: node.detail.kind } : null;
|
|
173
|
+
case "entryPoint":
|
|
174
|
+
return node.detail.summary ? { kind: "text", text: "summary" } : null;
|
|
175
|
+
default:
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build the Explore tree rows (port of the page's `listTree`).
|
|
182
|
+
*
|
|
183
|
+
* - Roots: `vault`, `repository` (nodes with no incoming containment edge).
|
|
184
|
+
* - Entry points nest under their prefix module.
|
|
185
|
+
* - Internals hidden by default; the caller toggles `showInternals`.
|
|
186
|
+
* - Filter (query and/or provenance): prune to matches + ancestors, auto-expanding ancestors.
|
|
187
|
+
* - Default expansion (no filter): controlled by `state.expanded`.
|
|
188
|
+
*/
|
|
189
|
+
export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
|
|
190
|
+
const idx = indexTree(model);
|
|
191
|
+
const { byId, tree, moduleEntries, roots } = idx;
|
|
192
|
+
|
|
193
|
+
const children = (id: string): string[] => {
|
|
194
|
+
const kids = tree.get(id) ?? [];
|
|
195
|
+
// Drop entry points that have a claiming module (they re-nest under it).
|
|
196
|
+
const filtered = kids.filter((kid) => {
|
|
197
|
+
const n = byId.get(kid);
|
|
198
|
+
if (!n || n.kind !== "entryPoint") return true;
|
|
199
|
+
for (const [, entries] of moduleEntries) {
|
|
200
|
+
if (entries.includes(kid)) return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
let all = filtered.concat(moduleEntries.get(id) ?? []);
|
|
205
|
+
// Skip kids absent from the node set (corrupt/hand-built graphs): the page
|
|
206
|
+
// never produces these, but a defensive filter keeps the tree robust.
|
|
207
|
+
all = all.filter((k) => byId.has(k));
|
|
208
|
+
if (!state.showInternals) {
|
|
209
|
+
all = all.filter((k) => {
|
|
210
|
+
const n = byId.get(k);
|
|
211
|
+
return !(n && hiddenInternalKind(n.kind));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return sortKids(all, byId);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const filtering = state.provFilter !== null || state.query.length > 0;
|
|
218
|
+
|
|
219
|
+
const matches = (node: GraphNode): boolean => {
|
|
220
|
+
if (state.provFilter !== null && node.provenance !== state.provFilter) return false;
|
|
221
|
+
if (state.query.length > 0 && !listLabel(node).toLowerCase().includes(state.query.toLowerCase())) return false;
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Pass 1: mark visible nodes (self-match or a visible descendant).
|
|
226
|
+
const visible = new Set<string>();
|
|
227
|
+
const mark = (id: string): boolean => {
|
|
228
|
+
const node = byId.get(id)!;
|
|
229
|
+
let any = false;
|
|
230
|
+
for (const k of children(id)) if (mark(k)) any = true;
|
|
231
|
+
const show = matches(node) || any;
|
|
232
|
+
if (show) visible.add(id);
|
|
233
|
+
return show;
|
|
234
|
+
};
|
|
235
|
+
for (const r of roots) mark(r);
|
|
236
|
+
|
|
237
|
+
const rows: TreeRow[] = [];
|
|
238
|
+
const walk = (id: string, depth: number): void => {
|
|
239
|
+
if (!visible.has(id)) return;
|
|
240
|
+
const node = byId.get(id)!;
|
|
241
|
+
const kids = children(id);
|
|
242
|
+
const visibleKids = kids.filter((k) => visible.has(k));
|
|
243
|
+
const expanded = filtering ? visibleKids.length > 0 : state.expanded.has(id);
|
|
244
|
+
rows.push({
|
|
245
|
+
id,
|
|
246
|
+
depth,
|
|
247
|
+
hasKids: kids.length > 0,
|
|
248
|
+
expanded,
|
|
249
|
+
label: listLabel(node),
|
|
250
|
+
kind: node.kind,
|
|
251
|
+
provenance: node.provenance,
|
|
252
|
+
meta: metaFor(node),
|
|
253
|
+
});
|
|
254
|
+
if (expanded) for (const k of visibleKids) walk(k, depth + 1);
|
|
255
|
+
};
|
|
256
|
+
for (const r of roots) walk(r, 0);
|
|
257
|
+
return rows;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Empty-state hint for the tree surface (weave-view-tui-design §5.1). */
|
|
261
|
+
export function treeEmptyHint(model: GraphModel): string | null {
|
|
262
|
+
const vault = model.nodes.find((n) => n.kind === "vault");
|
|
263
|
+
if (!vault) return null;
|
|
264
|
+
// no notes and no repository
|
|
265
|
+
const hasNotes = model.nodes.some((n) => n.kind === "note");
|
|
266
|
+
const hasRepo = model.nodes.some((n) => n.kind === "repository");
|
|
267
|
+
if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared row/meta types for the portable view-models (weave-workspace §3).
|
|
3
|
+
*
|
|
4
|
+
* These are *renderer-agnostic*: no terminal widths, no pre-truncation, no
|
|
5
|
+
* DOM. The TUI and the browser workspace both consume them and each applies
|
|
6
|
+
* its own formatting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A selectable row in any surface. `target` (when set) is the node id a row
|
|
10
|
+
* jumps to on activation (a link / neighbor). */
|
|
11
|
+
export interface SelectableRow {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Node id this row jumps to on activation (links/backlinks/neighbors). */
|
|
14
|
+
target?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The dim trailing annotation on a tree row, as structured data.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately *not* a formatted string: a terminal column, an HTML `<span>`,
|
|
21
|
+
* and a `<time>` element each want something different, and a relative time
|
|
22
|
+
* has to be re-derived as the clock moves. `formatTreeMeta` (view/time.ts)
|
|
23
|
+
* reproduces the terminal phrasing; other renderers are free to ignore it.
|
|
24
|
+
*/
|
|
25
|
+
export type TreeMeta =
|
|
26
|
+
/** Relative timestamp, e.g. a note's `updated`. Rendered against a clock. */
|
|
27
|
+
| { kind: "relTime"; iso: string }
|
|
28
|
+
/**
|
|
29
|
+
* A counted quantity. `phrasing` is a display hint, not a hard rule:
|
|
30
|
+
* `"attribute"` reads as `unit=n` (a property of the row), `"prose"` reads
|
|
31
|
+
* as `n unit` (a description of it).
|
|
32
|
+
*/
|
|
33
|
+
| { kind: "count"; n: number; unit: string; phrasing: "attribute" | "prose" }
|
|
34
|
+
/** A full commit sha; renderers abbreviate to taste. */
|
|
35
|
+
| { kind: "commit"; sha: string }
|
|
36
|
+
/** An opaque, already-human string (a package kind, a marker word). */
|
|
37
|
+
| { kind: "text"; text: string }
|
|
38
|
+
/** No annotation. */
|
|
39
|
+
| null;
|
package/src/pi/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { registerNoteTool } from "./tools/noteTool";
|
|
|
13
13
|
import { registerRepoTool } from "./tools/repoTool";
|
|
14
14
|
import { deepScanRepository, formatDeepScanResult } from "./summarize";
|
|
15
15
|
import { runWeaveViewTui } from "./viewer/tui/run";
|
|
16
|
+
import { WebWorkspaceController } from "./viewer/web/run";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* pi-weave — an agent-native knowledge workspace (docs/design.md):
|
|
@@ -27,13 +28,21 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
27
28
|
registerNoteTool(pi);
|
|
28
29
|
registerRepoTool(pi);
|
|
29
30
|
|
|
30
|
-
// /weave-view
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// /weave-view opens the browser workspace by default (weave-workspace §13);
|
|
32
|
+
// `tui` selects the in-terminal explorer. The browser workspace owns a
|
|
33
|
+
// server, a file watcher and live SSE streams for the rest of the session,
|
|
34
|
+
// so unlike the TUI it has a real teardown — see `session_shutdown` below.
|
|
33
35
|
let lastCtx: ExtensionContext | ExtensionCommandContext | null = null;
|
|
34
36
|
let lastStatusText: string | undefined = undefined;
|
|
35
37
|
let isActive = false;
|
|
36
38
|
|
|
39
|
+
const web = new WebWorkspaceController({
|
|
40
|
+
exec: (command, args) => pi.exec(command, args),
|
|
41
|
+
// A boot or an idle shutdown changes what the status line should say, and
|
|
42
|
+
// neither happens inside a command handler that could refresh it itself.
|
|
43
|
+
onStateChange: () => updateStatus(),
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
function updateStatus(ctx?: ExtensionContext | ExtensionCommandContext, text?: string): void {
|
|
38
47
|
if (ctx) lastCtx = ctx;
|
|
39
48
|
const c = ctx || lastCtx;
|
|
@@ -41,10 +50,28 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
41
50
|
if (text !== undefined) {
|
|
42
51
|
lastStatusText = text;
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
|
|
54
|
+
// A running browser workspace is a background process the user cannot
|
|
55
|
+
// otherwise see — an open port on their machine — so it always gets a
|
|
56
|
+
// marker. Resolved *before* the "nothing to show" test, because the port
|
|
57
|
+
// is itself something to show.
|
|
58
|
+
//
|
|
59
|
+
// This ordering is the fix for a real bug: the early return used to sit
|
|
60
|
+
// above this lookup, so a workspace started in a session that never
|
|
61
|
+
// emitted `session_start` (no seeded `lastStatusText`) did not merely
|
|
62
|
+
// fail to gain the marker — it actively cleared the status line while a
|
|
63
|
+
// server was listening. The browser-launch-failure path is exactly that
|
|
64
|
+
// shape, which is why it was the case that caught it.
|
|
65
|
+
const port = web.port();
|
|
66
|
+
const suffix = port === null ? "" : ` · web:${port}`;
|
|
67
|
+
|
|
68
|
+
// Clear only when there is genuinely nothing to say: no base text *and*
|
|
69
|
+
// no server.
|
|
70
|
+
if (!lastStatusText && port === null) {
|
|
45
71
|
c.ui.setStatus("weave", undefined);
|
|
46
72
|
return;
|
|
47
73
|
}
|
|
74
|
+
|
|
48
75
|
let theme: { fg?: (slot: string, text: string) => string } | undefined;
|
|
49
76
|
try {
|
|
50
77
|
theme = (c.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
|
|
@@ -54,11 +81,19 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
54
81
|
const indicator = (isActive || inFlightDeepScans.size > 0)
|
|
55
82
|
? (theme?.fg ? theme.fg("accent", "●") : "●")
|
|
56
83
|
: (theme?.fg ? theme.fg("dim", "○") : "○");
|
|
57
|
-
|
|
84
|
+
// With no base text the marker stands alone (`○ web:51234`) rather than
|
|
85
|
+
// rendering a leading ` · ` separator against nothing.
|
|
86
|
+
const body = lastStatusText ? `${lastStatusText}${suffix}` : `web:${port}`;
|
|
87
|
+
c.ui.setStatus("weave", `${indicator} ${body}`);
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
pi.on("session_shutdown", async () => {
|
|
61
|
-
// The TUI explorer owns
|
|
91
|
+
// The TUI explorer owns nothing session-scoped, but the browser workspace
|
|
92
|
+
// owns an HTTP server, a recursive file watcher and any number of open SSE
|
|
93
|
+
// streams. `close()` releases all three (server.ts closes the hub and
|
|
94
|
+
// awaits the watcher); a leaked listener outliving the pi session is a
|
|
95
|
+
// real bug, not a tidiness concern.
|
|
96
|
+
await web.close();
|
|
62
97
|
});
|
|
63
98
|
|
|
64
99
|
pi.on("agent_start", async (_event, ctx) => {
|
|
@@ -97,14 +132,26 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
97
132
|
});
|
|
98
133
|
|
|
99
134
|
pi.registerCommand("weave-view", {
|
|
100
|
-
description: "Open the in-terminal
|
|
135
|
+
description: "Open the knowledge workspace in your browser ('tui' for the in-terminal explorer, '--no-open' to just print the URL)",
|
|
101
136
|
handler: async (args, ctx) => {
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
ctx.ui.notify(
|
|
137
|
+
const parsed = parseWeaveViewArgs(args);
|
|
138
|
+
if (parsed === null) {
|
|
139
|
+
ctx.ui.notify(WEAVE_VIEW_USAGE, "warning");
|
|
105
140
|
return;
|
|
106
141
|
}
|
|
107
|
-
|
|
142
|
+
if (parsed.surface === "tui") {
|
|
143
|
+
await runWeaveViewTui(ctx);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const outcome = await web.run(ctx, { open: parsed.open });
|
|
147
|
+
// A browser that would not launch on a machine that has a terminal
|
|
148
|
+
// still leaves the user somewhere to go. The server stays up — the URL
|
|
149
|
+
// in the notification remains valid — and the TUI opens on top of it.
|
|
150
|
+
if (outcome.fallbackToTui && ctx.mode === "tui") await runWeaveViewTui(ctx);
|
|
151
|
+
// Unconditionally, and last: the TUI writes its own status line on
|
|
152
|
+
// close, which would otherwise drop the `· web:PORT` marker for a
|
|
153
|
+
// server that is still running.
|
|
154
|
+
updateStatus(ctx);
|
|
108
155
|
},
|
|
109
156
|
});
|
|
110
157
|
|
|
@@ -156,6 +203,52 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
156
203
|
});
|
|
157
204
|
}
|
|
158
205
|
|
|
206
|
+
/* ------------------------------------------------------------------ */
|
|
207
|
+
/* /weave-view argument parsing */
|
|
208
|
+
/* ------------------------------------------------------------------ */
|
|
209
|
+
|
|
210
|
+
export const WEAVE_VIEW_USAGE = "usage: /weave-view [tui|web] [--no-open]";
|
|
211
|
+
|
|
212
|
+
/** Which explorer `/weave-view` was asked for. */
|
|
213
|
+
export interface WeaveViewArgs {
|
|
214
|
+
surface: "tui" | "web";
|
|
215
|
+
/** `false` for `--no-open`. Meaningless for `tui`, which never opens one. */
|
|
216
|
+
open: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Parse `/weave-view`'s argument string, or `null` for "print the usage".
|
|
221
|
+
*
|
|
222
|
+
* Bare `/weave-view` is the **browser** workspace (weave-workspace §13):
|
|
223
|
+
* the default is the thing most people want most of the time, and `tui` is
|
|
224
|
+
* one word away for the SSH case. `--no-open` is accepted on the web
|
|
225
|
+
* surface only — silently ignoring it after `tui` would be a lie about what
|
|
226
|
+
* happened.
|
|
227
|
+
*
|
|
228
|
+
* Exported because a parser is exactly the kind of thing that should be
|
|
229
|
+
* tested as a table rather than through eight command invocations.
|
|
230
|
+
*/
|
|
231
|
+
export function parseWeaveViewArgs(args: string): WeaveViewArgs | null {
|
|
232
|
+
const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
233
|
+
let surface: "tui" | "web" = "web";
|
|
234
|
+
let open = true;
|
|
235
|
+
let sawSurface = false;
|
|
236
|
+
for (const token of tokens) {
|
|
237
|
+
if (token === "--no-open") {
|
|
238
|
+
// Rejecting the repeat costs nothing and keeps "did I typo?" honest.
|
|
239
|
+
if (!open) return null;
|
|
240
|
+
open = false;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (sawSurface) return null;
|
|
244
|
+
if (token !== "tui" && token !== "web") return null;
|
|
245
|
+
surface = token;
|
|
246
|
+
sawSurface = true;
|
|
247
|
+
}
|
|
248
|
+
if (surface === "tui" && !open) return null;
|
|
249
|
+
return { surface, open };
|
|
250
|
+
}
|
|
251
|
+
|
|
159
252
|
/* ------------------------------------------------------------------ */
|
|
160
253
|
/* In-flight deep scans (background, cancellable) */
|
|
161
254
|
/* ------------------------------------------------------------------ */
|
|
@@ -21,6 +21,7 @@ import type { ViewNote } from "../../../core/graph/current";
|
|
|
21
21
|
import {
|
|
22
22
|
detailModel,
|
|
23
23
|
focusModel,
|
|
24
|
+
formatTreeMeta,
|
|
24
25
|
graphRoots,
|
|
25
26
|
healthModel,
|
|
26
27
|
initialState,
|
|
@@ -359,8 +360,8 @@ export class WeaveExplorer implements Component {
|
|
|
359
360
|
showInternals: this.state.showInternals,
|
|
360
361
|
provFilter: this.state.provFilter,
|
|
361
362
|
query: this.state.query,
|
|
362
|
-
now: this.nowFn(),
|
|
363
363
|
});
|
|
364
|
+
const now = this.nowFn();
|
|
364
365
|
const hint = treeEmptyHint(this.model);
|
|
365
366
|
if (rows.length === 0 && hint) {
|
|
366
367
|
return { lines: [{ text: t.fg("dim", hint) }], rows: [] };
|
|
@@ -369,7 +370,8 @@ export class WeaveExplorer implements Component {
|
|
|
369
370
|
const chev = chevron(r.expanded, r.hasKids);
|
|
370
371
|
const marker = this.rowMarker(r.kind, r.provenance);
|
|
371
372
|
const label = sanitizeTerminalText(r.label);
|
|
372
|
-
const
|
|
373
|
+
const metaText = formatTreeMeta(r.meta, now);
|
|
374
|
+
const meta = metaText ? t.fg("dim", ` ${sanitizeTerminalText(metaText)}`) : "";
|
|
373
375
|
const indent = " ".repeat(r.depth);
|
|
374
376
|
const body = `${indent}${chev} ${marker}${label}`;
|
|
375
377
|
const text = meta ? `${body}${meta}` : body;
|