pi-weave 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +1 -1
- package/src/web/client/dist/app.js +52 -39
- package/src/web/client/graph/Graph.tsx +70 -19
- package/src/web/client/graph/column.model.ts +11 -70
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +10 -2
- package/src/web/client/graph/positions.ts +51 -10
- package/src/web/client/graph/renderer.ts +61 -1
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Header.tsx +11 -1
- package/src/web/client/shell/Shell.tsx +17 -0
- package/src/web/client/shell/theme.ts +15 -2
- package/src/web/client/state.ts +11 -0
- package/src/web/client/tree/Tree.tsx +4 -1
- package/src/web/client/workspace.ts +69 -5
- package/src/web/server/page.ts +2 -0
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +158 -341
- package/src/web/shared/logo.ts +11 -0
|
@@ -94,6 +94,12 @@ export interface GraphRenderer {
|
|
|
94
94
|
fit(): void;
|
|
95
95
|
/** Current positions, for warm-starting a re-run. */
|
|
96
96
|
positions(): Map<string, Point>;
|
|
97
|
+
/** Called when the user starts dragging a node. */
|
|
98
|
+
onDragStart(handler: (id: string) => void): void;
|
|
99
|
+
/** Called with graph-space coordinates while a node is dragged. */
|
|
100
|
+
onDragMove(handler: (id: string, at: Point) => void): void;
|
|
101
|
+
/** Called when a node drag ends. */
|
|
102
|
+
onDragEnd(handler: (id: string) => void): void;
|
|
97
103
|
/** Tear down. Idempotent, and must leave no listener behind. */
|
|
98
104
|
destroy(): void;
|
|
99
105
|
}
|
|
@@ -130,6 +136,9 @@ export function nullRenderer(): GraphRenderer {
|
|
|
130
136
|
setHighlight() {},
|
|
131
137
|
onSelect() {},
|
|
132
138
|
fit() {},
|
|
139
|
+
onDragStart() {},
|
|
140
|
+
onDragMove() {},
|
|
141
|
+
onDragEnd() {},
|
|
133
142
|
positions() {
|
|
134
143
|
return new Map();
|
|
135
144
|
},
|
|
@@ -174,8 +183,13 @@ export interface CameraLike {
|
|
|
174
183
|
export interface SigmaLike {
|
|
175
184
|
on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
|
|
176
185
|
on(event: "clickStage", handler: () => void): unknown;
|
|
186
|
+
on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
|
|
187
|
+
on(event: "moveBody", handler: (payload: { event: { x: number; y: number }; preventSigmaDefault(): void }) => void): unknown;
|
|
188
|
+
on(event: "upNode" | "upStage", handler: () => void): unknown;
|
|
189
|
+
viewportToGraph(position: { x: number; y: number }): Point;
|
|
177
190
|
setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
|
|
178
191
|
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge) => EdgeDisplayOverride): unknown;
|
|
192
|
+
setSetting(key: "enableCameraPanning", value: boolean): unknown;
|
|
179
193
|
setGraph(graph: ProjectedGraph): unknown;
|
|
180
194
|
refresh(): unknown;
|
|
181
195
|
getCamera(): CameraLike;
|
|
@@ -212,6 +226,21 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
212
226
|
let graph: ProjectedGraph = project({ nodes: [], edges: [] });
|
|
213
227
|
let highlight: ReadonlySet<string> | null = null;
|
|
214
228
|
let select: (id: string | null) => void = () => {};
|
|
229
|
+
let dragStart: (id: string) => void = () => {};
|
|
230
|
+
let dragMove: (id: string, at: Point) => void = () => {};
|
|
231
|
+
let dragEnd: (id: string) => void = () => {};
|
|
232
|
+
let dragging: string | null = null;
|
|
233
|
+
|
|
234
|
+
const endDrag = (id: string | null): void => {
|
|
235
|
+
if (dragging === null) return;
|
|
236
|
+
const current = dragging;
|
|
237
|
+
dragging = null;
|
|
238
|
+
// A node drag is a pin, not a pan: sigma's captor would otherwise move the
|
|
239
|
+
// camera on the same gesture that is moving the node, and the node would
|
|
240
|
+
// slide away from the cursor. Re-enable panning only when the drag ends.
|
|
241
|
+
sigma?.setSetting("enableCameraPanning", true);
|
|
242
|
+
if (id === null || id === current) dragEnd(current);
|
|
243
|
+
};
|
|
215
244
|
|
|
216
245
|
/**
|
|
217
246
|
* Push the current highlight into sigma's reducers (§7.4).
|
|
@@ -236,6 +265,26 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
236
265
|
// the tree and the context rail all recompute from it.
|
|
237
266
|
instance.on("clickNode", ({ node }) => select(node));
|
|
238
267
|
instance.on("clickStage", () => select(null));
|
|
268
|
+
instance.on("downNode", ({ node }) => {
|
|
269
|
+
dragging = node;
|
|
270
|
+
// Hold the view still while the node follows the cursor (see endDrag).
|
|
271
|
+
sigma?.setSetting("enableCameraPanning", false);
|
|
272
|
+
dragStart(node);
|
|
273
|
+
});
|
|
274
|
+
instance.on("moveBody", (payload) => {
|
|
275
|
+
if (dragging !== null) {
|
|
276
|
+
// Sigma's captor pans the camera on every mouse move while the
|
|
277
|
+
// button is down; during a node drag that is the pan the gesture
|
|
278
|
+
// must not be. `preventSigmaDefault` is the captor's own gate — it
|
|
279
|
+
// is consulted right after this handler returns — and without it
|
|
280
|
+
// the camera follows the cursor 1:1 while the pin follows it too,
|
|
281
|
+
// so the node appears to slide away under the view.
|
|
282
|
+
payload.preventSigmaDefault();
|
|
283
|
+
dragMove(dragging, instance.viewportToGraph({ x: payload.event.x, y: payload.event.y }));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
instance.on("upNode", () => endDrag(dragging));
|
|
287
|
+
instance.on("upStage", () => endDrag(null));
|
|
239
288
|
applyReducers(instance);
|
|
240
289
|
sigma = instance;
|
|
241
290
|
},
|
|
@@ -261,6 +310,18 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
261
310
|
select = handler;
|
|
262
311
|
},
|
|
263
312
|
|
|
313
|
+
onDragStart(handler) {
|
|
314
|
+
dragStart = handler;
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
onDragMove(handler) {
|
|
318
|
+
dragMove = handler;
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
onDragEnd(handler) {
|
|
322
|
+
dragEnd = handler;
|
|
323
|
+
},
|
|
324
|
+
|
|
264
325
|
fit() {
|
|
265
326
|
// `animatedReset` settles when the animation ends. Nothing awaits a
|
|
266
327
|
// camera move, and a rejection from a camera killed mid-flight is noise.
|
|
@@ -270,7 +331,6 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
270
331
|
positions() {
|
|
271
332
|
return positionsOf(graph);
|
|
272
333
|
},
|
|
273
|
-
|
|
274
334
|
destroy() {
|
|
275
335
|
sigma?.kill();
|
|
276
336
|
sigma = null;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection.storage.ts — the §1.3 context bus's memory across reloads.
|
|
3
|
+
*
|
|
4
|
+
* A manual refresh used to drop the open note: `selectedId` is a Preact
|
|
5
|
+
* signal, and signals do not survive a page load. The layout already
|
|
6
|
+
* persists (`shell/layout.model.ts`), and this is the same trick for the
|
|
7
|
+
* one piece of selection state the user actually misses: which note they
|
|
8
|
+
* were reading.
|
|
9
|
+
*
|
|
10
|
+
* ## Restore needs the graph
|
|
11
|
+
*
|
|
12
|
+
* A saved id is only offered back if it still names a node of the freshly
|
|
13
|
+
* loaded graph — a note deleted in another window must not resurrect as a
|
|
14
|
+
* selection pointing at nothing. That check needs the graph, which arrives
|
|
15
|
+
* after mount, so the restore is deliberately a pure function of
|
|
16
|
+
* `(graph, storage)` the shell calls once the first graph lands, not
|
|
17
|
+
* something this module schedules itself.
|
|
18
|
+
*
|
|
19
|
+
* ## Tier rules (§2)
|
|
20
|
+
*
|
|
21
|
+
* `src/web/client/**`: storage arrives as a two-method port (same shape as
|
|
22
|
+
* `LayoutStorage`) rather than the global, because the real `localStorage`
|
|
23
|
+
* throws in partitioned-storage contexts and a module this small should be
|
|
24
|
+
* testable without a DOM.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { GraphPayload } from "../shared/wire";
|
|
28
|
+
|
|
29
|
+
/** The slice of `Storage` this module needs. */
|
|
30
|
+
export interface SelectionStorage {
|
|
31
|
+
getItem(key: string): string | null;
|
|
32
|
+
setItem(key: string, value: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Versioned like `pi-weave.layout.v1`: an id-shape change gets a new key. */
|
|
36
|
+
export const SELECTION_STORAGE_KEY = "pi-weave.selection.v1";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remember the selection; an empty string records "nothing open".
|
|
40
|
+
* Reports failure as `false` instead of throwing — a quota or
|
|
41
|
+
* partitioned-storage error must never break a selection.
|
|
42
|
+
*/
|
|
43
|
+
export function saveSelection(storage: SelectionStorage, id: string | null): boolean {
|
|
44
|
+
try {
|
|
45
|
+
storage.setItem(SELECTION_STORAGE_KEY, id ?? "");
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The saved selection to restore, or `null`.
|
|
54
|
+
*
|
|
55
|
+
* `graph` gates the offer: the id must still name a node of the current
|
|
56
|
+
* payload, so a note deleted elsewhere is quietly not restored. Reads are
|
|
57
|
+
* failure-absorbing for the same reason writes are.
|
|
58
|
+
*/
|
|
59
|
+
export function restoreSelection(graph: GraphPayload | null, storage: SelectionStorage): string | null {
|
|
60
|
+
if (graph === null) return null;
|
|
61
|
+
let raw: string | null;
|
|
62
|
+
try {
|
|
63
|
+
raw = storage.getItem(SELECTION_STORAGE_KEY);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (raw === null || raw === "") return null;
|
|
68
|
+
return graph.model.nodes.some((node) => node.id === raw) ? raw : null;
|
|
69
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* opened would be a worse lie than the disabled version it replaced.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
|
|
16
17
|
import type { ConnectionView, HeaderSummary } from "./shell.model";
|
|
17
18
|
import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
|
|
18
19
|
|
|
@@ -28,7 +29,16 @@ export interface HeaderProps {
|
|
|
28
29
|
export function Header(props: HeaderProps) {
|
|
29
30
|
return (
|
|
30
31
|
<header class="weave-header">
|
|
31
|
-
<span class="weave-brand"
|
|
32
|
+
<span class="weave-brand">
|
|
33
|
+
<img
|
|
34
|
+
class="weave-brand-mark"
|
|
35
|
+
src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
|
|
36
|
+
alt=""
|
|
37
|
+
width={18}
|
|
38
|
+
height={18}
|
|
39
|
+
/>
|
|
40
|
+
pi-weave
|
|
41
|
+
</span>
|
|
32
42
|
<button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
|
|
33
43
|
<span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
|
|
34
44
|
<kbd>{props.shortcut}</kbd>
|
|
@@ -30,6 +30,7 @@ import { domEventSource } from "../live";
|
|
|
30
30
|
import { createEditor, watchUnload, type EditorHandle } from "../note/editor.controller";
|
|
31
31
|
import { editorPrompt, editorToolbar, initialEditorState, shouldBlockUnload } from "../note/editor.model";
|
|
32
32
|
import { SearchPalette } from "../search/SearchPalette";
|
|
33
|
+
import { restoreSelection, saveSelection } from "../selection.storage";
|
|
33
34
|
import { connection, graph, noteBody, selectedId } from "../state";
|
|
34
35
|
import type { WorkspaceHandle } from "../workspace";
|
|
35
36
|
import { observeNotes, select, startWorkspace } from "../workspace";
|
|
@@ -83,6 +84,22 @@ export function Shell(props: ShellProps) {
|
|
|
83
84
|
return () => handle.stop();
|
|
84
85
|
}, []);
|
|
85
86
|
|
|
87
|
+
// §1.3 continuity: a reload keeps the note you were reading. Saving is
|
|
88
|
+
// gated on the restore decision so the mount-time `null` cannot wipe the
|
|
89
|
+
// saved id before the first graph arrives to validate it against.
|
|
90
|
+
const selectionRestored = useRef(false);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!selectionRestored.current) return;
|
|
93
|
+
saveSelection(localStorage, selectedId.value);
|
|
94
|
+
}, [selectedId.value]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (selectionRestored.current || graph.value === null) return;
|
|
97
|
+
selectionRestored.current = true;
|
|
98
|
+
if (selectedId.value !== null) return;
|
|
99
|
+
const saved = restoreSelection(graph.value, localStorage);
|
|
100
|
+
if (saved !== null) void select(fetchJson, saved);
|
|
101
|
+
}, [graph.value]);
|
|
102
|
+
|
|
86
103
|
// Every note that arrives, from any of the three directions it can arrive
|
|
87
104
|
// from (mount, selection, SSE refetch), so the editor can decide whether it
|
|
88
105
|
// is news or an interruption — see `editor.model.ts`'s header.
|
|
@@ -69,6 +69,7 @@ export const THEME_CSS = `
|
|
|
69
69
|
--weave-bg:#141317;--weave-panel:#191820;--weave-fg:#e8e6ee;--weave-dim:#8f8a9c;
|
|
70
70
|
--weave-faint:#5d5869;--weave-line:#26242e;--weave-line-strong:#343141;
|
|
71
71
|
--weave-accent:#a48cff;--weave-ok:#4ade80;--weave-warn:#fbbf24;--weave-bad:#f87171;
|
|
72
|
+
--weave-new:rgba(164,140,255,.20);
|
|
72
73
|
--weave-row:26px;--weave-gutter:10px;
|
|
73
74
|
}
|
|
74
75
|
@media (prefers-color-scheme: light){
|
|
@@ -76,6 +77,7 @@ export const THEME_CSS = `
|
|
|
76
77
|
--weave-bg:#faf9f7;--weave-panel:#ffffff;--weave-fg:#1c1b19;--weave-dim:#6f6b66;
|
|
77
78
|
--weave-faint:#9a958e;--weave-line:#e6e3de;--weave-line-strong:#d2cec8;
|
|
78
79
|
--weave-accent:#6d4aff;--weave-ok:#15803d;--weave-warn:#b45309;--weave-bad:#b91c1c;
|
|
80
|
+
--weave-new:rgba(109,74,255,.14);
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
body{font-size:13px}
|
|
@@ -86,7 +88,8 @@ body{font-size:13px}
|
|
|
86
88
|
display:flex;align-items:center;gap:14px;padding:0 var(--weave-gutter);
|
|
87
89
|
height:34px;border-bottom:1px solid var(--weave-line);background:var(--weave-panel);
|
|
88
90
|
}
|
|
89
|
-
.weave-brand{font-weight:600;letter-spacing:.01em;white-space:nowrap}
|
|
91
|
+
.weave-brand{display:inline-flex;align-items:center;gap:6px;font-size:14px;font-weight:600;letter-spacing:.01em;white-space:nowrap}
|
|
92
|
+
.weave-brand-mark{height:20px;width:20px;border-radius:5px}
|
|
90
93
|
/* A button styled as a search field: the palette owns the only text input,
|
|
91
94
|
so this opens it rather than pretending to accept a query. */
|
|
92
95
|
.weave-search{
|
|
@@ -185,6 +188,17 @@ body{font-size:13px}
|
|
|
185
188
|
}
|
|
186
189
|
.weave-row:hover{background:var(--weave-line)}
|
|
187
190
|
.weave-row-on{background:var(--weave-line-strong);color:var(--weave-fg)}
|
|
191
|
+
/* A newly-arrived node (a file or note added since the last update, §6):
|
|
192
|
+
one short highlight that fades while the label settles from bold back to
|
|
193
|
+
normal. The class is computed from the frame diff in workspace.ts and
|
|
194
|
+
expires with it, so collapsing and re-expanding later does not replay the
|
|
195
|
+
arrival, and the first load never flashes the whole tree. */
|
|
196
|
+
.weave-row-new{animation:weave-row-new 2.6s ease-out both}
|
|
197
|
+
@keyframes weave-row-new{
|
|
198
|
+
0%{background:var(--weave-new);font-weight:700}
|
|
199
|
+
40%{font-weight:700}
|
|
200
|
+
100%{background:transparent;font-weight:400}
|
|
201
|
+
}
|
|
188
202
|
.weave-twisty{width:10px;flex:none;color:var(--weave-faint);cursor:pointer;text-align:center}
|
|
189
203
|
.weave-kind{flex:none;color:var(--weave-faint);font-size:11px}
|
|
190
204
|
.weave-prov{flex:none;font-size:9px}
|
|
@@ -309,7 +323,6 @@ body{font-size:13px}
|
|
|
309
323
|
display:flex;align-items:center;gap:6px;flex-wrap:wrap;
|
|
310
324
|
padding:5px var(--weave-gutter);border-top:1px solid var(--weave-line);
|
|
311
325
|
}
|
|
312
|
-
.weave-depth{font:inherit;font-size:10.5px;padding:3px 4px}
|
|
313
326
|
.weave-graph-legend{
|
|
314
327
|
margin-left:auto;display:flex;gap:9px;font-size:10.5px;color:var(--weave-faint);
|
|
315
328
|
white-space:nowrap;
|
package/src/web/client/state.ts
CHANGED
|
@@ -76,3 +76,14 @@ export const treeState = signal<TreeState>(initialTreeState());
|
|
|
76
76
|
|
|
77
77
|
/** SSE liveness (§6). */
|
|
78
78
|
export const connection = signal<ConnectionState>("live");
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Node ids that appeared in the most recent graph update (weave-workspace
|
|
82
|
+
* §6 frame → tree feedback).
|
|
83
|
+
*
|
|
84
|
+
* Empty on the mount fetch — a first load must not animate the whole tree.
|
|
85
|
+
* The controller replaces the set on every published payload and clears it
|
|
86
|
+
* after {@link ../workspace!RECENT_TTL_MS}, so a row collapsed and re-expanded
|
|
87
|
+
* minutes later does not replay its arrival animation.
|
|
88
|
+
*/
|
|
89
|
+
export const recentIds = signal<ReadonlySet<string>>(new Set());
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { useState } from "preact/hooks";
|
|
11
|
+
import { recentIds } from "../state";
|
|
11
12
|
import type { GraphPayload } from "../../shared/wire";
|
|
12
13
|
import type { TreeRowView, TreeViewState } from "./tree.model";
|
|
13
14
|
import {
|
|
@@ -45,7 +46,9 @@ function Row({ view, onSelect, onToggle }: { view: TreeRowView; onSelect: () =>
|
|
|
45
46
|
return (
|
|
46
47
|
<li
|
|
47
48
|
id={view.domId}
|
|
48
|
-
class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}
|
|
49
|
+
class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${
|
|
50
|
+
recentIds.value.has(view.id) ? " weave-row-new" : ""
|
|
51
|
+
}`}
|
|
49
52
|
role="treeitem"
|
|
50
53
|
aria-level={view.level}
|
|
51
54
|
aria-posinset={view.posinset}
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
* the socket, is what tells the user something is wrong.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import type { NotePayload } from "../shared/wire";
|
|
34
|
+
import type { GraphPayload, NotePayload } from "../shared/wire";
|
|
35
35
|
import type { ApiResult, FetchLike } from "./api";
|
|
36
36
|
import { fetchGraph, fetchNote } from "./api";
|
|
37
|
+
import { recentIds } from "./state";
|
|
37
38
|
import type { EventSourceFactory, LiveHandle } from "./live";
|
|
38
39
|
import { startLive } from "./live";
|
|
39
40
|
import type { RefetchPlan } from "./live.model";
|
|
@@ -46,6 +47,34 @@ export interface WorkspaceOptions {
|
|
|
46
47
|
open: EventSourceFactory;
|
|
47
48
|
/** Overrides the SSE path. Tests use it; the shell does not. */
|
|
48
49
|
path?: string;
|
|
50
|
+
/**
|
|
51
|
+
* One-shot timer, injectable for tests. Defaults to `setTimeout`. Used
|
|
52
|
+
* only to expire the recent-arrivals highlight ({@link RECENT_TTL_MS}).
|
|
53
|
+
*/
|
|
54
|
+
defer?: (fn: () => void, ms: number) => () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How long a newly-arrived node stays flagged in the tree (the animation is shorter). */
|
|
58
|
+
export const RECENT_TTL_MS = 3_000;
|
|
59
|
+
|
|
60
|
+
const NO_IDS: ReadonlySet<string> = new Set();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Node ids present in `next` but not in `previous`.
|
|
64
|
+
*
|
|
65
|
+
* The mount fetch passes `previous === null`, which yields the empty set —
|
|
66
|
+
* a first load must not animate the entire tree as "new". A node that left
|
|
67
|
+
* and returned is new again: from the reader's point of view it *is* a
|
|
68
|
+
* fresh arrival.
|
|
69
|
+
*/
|
|
70
|
+
export function addedNodeIds(previous: GraphPayload | null, next: GraphPayload): ReadonlySet<string> {
|
|
71
|
+
if (previous === null) return NO_IDS;
|
|
72
|
+
const before = new Set(previous.model.nodes.map((node) => node.id));
|
|
73
|
+
const added = new Set<string>();
|
|
74
|
+
for (const node of next.model.nodes) {
|
|
75
|
+
if (!before.has(node.id)) added.add(node.id);
|
|
76
|
+
}
|
|
77
|
+
return added;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
/**
|
|
@@ -97,10 +126,20 @@ export interface WorkspaceHandle {
|
|
|
97
126
|
* subscriber. Skipping it is the difference between an idle workspace doing
|
|
98
127
|
* nothing and one re-rendering three columns every time the watcher twitches.
|
|
99
128
|
*/
|
|
100
|
-
async function loadGraph(
|
|
129
|
+
async function loadGraph(
|
|
130
|
+
fetchImpl: FetchLike,
|
|
131
|
+
live: LiveHandle | null,
|
|
132
|
+
onPublished?: (previous: GraphPayload | null, next: GraphPayload) => void,
|
|
133
|
+
): Promise<ApiResult<unknown>> {
|
|
134
|
+
// Captured before the fetch so the diff describes exactly what the reader
|
|
135
|
+
// was looking at when the update landed.
|
|
136
|
+
const previous = graph.value;
|
|
101
137
|
const result = await fetchGraph(fetchImpl, graph.value);
|
|
102
138
|
if (!result.ok) return result;
|
|
103
|
-
if (!result.cached)
|
|
139
|
+
if (!result.cached) {
|
|
140
|
+
graph.value = result.data;
|
|
141
|
+
onPublished?.(previous, result.data);
|
|
142
|
+
}
|
|
104
143
|
live?.seen(result.data.stamp);
|
|
105
144
|
return result;
|
|
106
145
|
}
|
|
@@ -157,13 +196,34 @@ export function noteSlug(id: string | null): string | null {
|
|
|
157
196
|
*/
|
|
158
197
|
export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
159
198
|
let live: LiveHandle | null = null;
|
|
199
|
+
let cancelRecentExpiry: (() => void) | null = null;
|
|
200
|
+
const defer =
|
|
201
|
+
opts.defer ??
|
|
202
|
+
((fn: () => void, ms: number) => {
|
|
203
|
+
const handle = setTimeout(fn, ms);
|
|
204
|
+
return () => clearTimeout(handle);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** Publish the frame's arrivals; the tree flashes them while they are new. */
|
|
208
|
+
const onPublished = (previous: GraphPayload | null, next: GraphPayload): void => {
|
|
209
|
+
const added = addedNodeIds(previous, next);
|
|
210
|
+
recentIds.value = added;
|
|
211
|
+
cancelRecentExpiry?.();
|
|
212
|
+
cancelRecentExpiry = null;
|
|
213
|
+
if (added.size > 0) {
|
|
214
|
+
cancelRecentExpiry = defer(() => {
|
|
215
|
+
cancelRecentExpiry = null;
|
|
216
|
+
recentIds.value = NO_IDS;
|
|
217
|
+
}, RECENT_TTL_MS);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
160
220
|
|
|
161
221
|
const runPlan = (plan: RefetchPlan): void => {
|
|
162
222
|
// Fire-and-forget: this is called from a socket callback, which cannot
|
|
163
223
|
// await. Failures are values (`api.ts`), so there is nothing to reject —
|
|
164
224
|
// `void` documents that rather than hiding a floating promise.
|
|
165
225
|
void (async () => {
|
|
166
|
-
if (plan.graph) await loadGraph(opts.fetch, live);
|
|
226
|
+
if (plan.graph) await loadGraph(opts.fetch, live, onPublished);
|
|
167
227
|
if (plan.note) await loadNote(opts.fetch);
|
|
168
228
|
})();
|
|
169
229
|
};
|
|
@@ -175,7 +235,7 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
|
175
235
|
...(opts.path === undefined ? {} : { path: opts.path }),
|
|
176
236
|
});
|
|
177
237
|
|
|
178
|
-
void loadGraph(opts.fetch, live);
|
|
238
|
+
void loadGraph(opts.fetch, live, onPublished);
|
|
179
239
|
|
|
180
240
|
return {
|
|
181
241
|
refresh() {
|
|
@@ -187,6 +247,9 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
|
187
247
|
stop() {
|
|
188
248
|
live?.stop();
|
|
189
249
|
live = null;
|
|
250
|
+
cancelRecentExpiry?.();
|
|
251
|
+
cancelRecentExpiry = null;
|
|
252
|
+
recentIds.value = NO_IDS;
|
|
190
253
|
},
|
|
191
254
|
};
|
|
192
255
|
}
|
|
@@ -211,4 +274,5 @@ export function resetWorkspace(): void {
|
|
|
211
274
|
graph.value = null;
|
|
212
275
|
noteBody.value = null;
|
|
213
276
|
connection.value = "live";
|
|
277
|
+
recentIds.value = NO_IDS;
|
|
214
278
|
}
|
package/src/web/server/page.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { randomBytes } from "node:crypto";
|
|
46
|
+
import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../shared/logo";
|
|
46
47
|
import type { Bootstrap } from "../shared/wire";
|
|
47
48
|
import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
|
|
48
49
|
|
|
@@ -236,6 +237,7 @@ export function renderPage(opts: RenderPageOptions): RenderedPage {
|
|
|
236
237
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
237
238
|
'<meta name="referrer" content="no-referrer">',
|
|
238
239
|
"<title>pi-weave workspace</title>",
|
|
240
|
+
`<link rel="icon" type="image/png" href="data:${escapeAttr(LOGO_MARK_MIME)};base64,${escapeAttr(LOGO_MARK_B64)}">`,
|
|
239
241
|
`<style nonce="${escapeAttr(nonce)}">`,
|
|
240
242
|
THEME_CSS,
|
|
241
243
|
"</style>",
|
package/src/web/server/routes.ts
CHANGED
|
@@ -363,6 +363,12 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
|
|
|
363
363
|
* prompted a refetch. A digest changes if and only if the bytes change, which
|
|
364
364
|
* is the property both consumers actually need.
|
|
365
365
|
*
|
|
366
|
+
* The digest is exact, but the payload is only an *excerpt* of the workspace:
|
|
367
|
+
* a note body below `PREVIEW_LEN` does not appear in the payload at all, so
|
|
368
|
+
* a body-only edit used to reproduce byte-identical payload and re-open the
|
|
369
|
+
* same hole through both dedupe layers. `GraphModel.contentDigest` — hashed
|
|
370
|
+
* over the note bodies themselves — closes that; see `core/graph/model.ts`.
|
|
371
|
+
*
|
|
366
372
|
* `generatedAt` keeps its own, different job: it is the human-facing
|
|
367
373
|
* "data as of" marker on the model, and the status bar still reads it.
|
|
368
374
|
*
|
|
@@ -657,11 +663,15 @@ function normalizeEtag(value: string): string {
|
|
|
657
663
|
* `DELETE /api/graph` and `PUT /api/note/x` answering the same `404` as any
|
|
658
664
|
* other unrouted request: the family claims a request or it does not.
|
|
659
665
|
*
|
|
660
|
-
* The `rest` after the slug is matched **exactly**, not by prefix.
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
666
|
+
* The `rest` after the slug is matched **exactly**, not by prefix. Slugs may
|
|
667
|
+
* nest (`sessions/foo` — session memory lives in a vault subdirectory), so
|
|
668
|
+
* the family has exactly one sub-resource path (`/rename`) and everything
|
|
669
|
+
* else after a `/` belongs to the slug itself; anything else a caller
|
|
670
|
+
* appends is a request for nothing and gets the `404` it deserves. Core's
|
|
671
|
+
* `resolveNotePath` remains the traversal guard for whatever slug arrives.
|
|
664
672
|
*/
|
|
673
|
+
const renameSuffix = "/rename";
|
|
674
|
+
|
|
665
675
|
async function routeNote(
|
|
666
676
|
deps: RouteDeps,
|
|
667
677
|
method: string,
|
|
@@ -669,9 +679,11 @@ async function routeNote(
|
|
|
669
679
|
req: IncomingMessage,
|
|
670
680
|
res: ServerResponse,
|
|
671
681
|
): Promise<boolean> {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
682
|
+
// One sub-resource, matched at the end: a nested slug can itself contain
|
|
683
|
+
// slashes, so the split is anchored at the end, not the first slash.
|
|
684
|
+
const isRename = target.endsWith(renameSuffix);
|
|
685
|
+
const slug = isRename ? target.slice(0, target.length - renameSuffix.length) : target;
|
|
686
|
+
const rest = isRename ? renameSuffix : "";
|
|
675
687
|
|
|
676
688
|
if (rest === "" && method === "GET") {
|
|
677
689
|
await sendNote(deps, slug, res);
|
package/src/web/shared/graph.ts
CHANGED
|
@@ -153,6 +153,13 @@ export interface WireGraphModel {
|
|
|
153
153
|
staleness: WireStalenessReport | null;
|
|
154
154
|
nodes: WireGraphNode[];
|
|
155
155
|
edges: WireGraphEdge[];
|
|
156
|
+
/**
|
|
157
|
+
* Content fingerprint of the note bodies (see core `GraphModel`). Rides
|
|
158
|
+
* the wire so `GraphPayload.stamp` — hashed over these bytes — moves on a
|
|
159
|
+
* body-only edit, which is what un-dedupes the SSE frame that refetches
|
|
160
|
+
* an open note.
|
|
161
|
+
*/
|
|
162
|
+
contentDigest: string;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
/**
|