pi-weave 0.3.0 → 0.3.2

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.
Files changed (50) hide show
  1. package/README.md +40 -16
  2. package/package.json +2 -2
  3. package/src/core/frontmatter.ts +3 -3
  4. package/src/core/graph/model.ts +2 -2
  5. package/src/core/graph/wikilinks.ts +1 -1
  6. package/src/core/types.ts +1 -1
  7. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  8. package/src/web/client/dist/app.js +239 -243
  9. package/src/web/client/graph/ForceTuner.tsx +14 -98
  10. package/src/web/client/graph/Graph.tsx +29 -74
  11. package/src/web/client/graph/column.model.ts +22 -15
  12. package/src/web/client/graph/graph.model.ts +31 -63
  13. package/src/web/client/graph/groups.ts +9 -7
  14. package/src/web/client/graph/positions.ts +3 -3
  15. package/src/web/client/graph/renderer.dom.ts +2 -2
  16. package/src/web/client/graph/renderer.ts +14 -5
  17. package/src/web/client/graph/tuner.model.ts +6 -180
  18. package/src/web/client/main.tsx +1 -4
  19. package/src/web/client/note/Note.tsx +77 -92
  20. package/src/web/client/note/drafts.ts +172 -0
  21. package/src/web/client/note/note.model.ts +2 -34
  22. package/src/web/client/search/SearchPalette.tsx +9 -8
  23. package/src/web/client/shell/ContextRail.tsx +8 -9
  24. package/src/web/client/shell/Settings.tsx +71 -0
  25. package/src/web/client/shell/Shell.tsx +409 -255
  26. package/src/web/client/shell/StatusBar.tsx +2 -6
  27. package/src/web/client/shell/focus.model.ts +2 -2
  28. package/src/web/client/shell/keys.model.ts +40 -39
  29. package/src/web/client/shell/keys.ts +1 -0
  30. package/src/web/client/shell/shell.model.ts +32 -128
  31. package/src/web/client/shell/theme.model.ts +27 -97
  32. package/src/web/client/shell/theme.ts +32 -238
  33. package/src/web/client/shell/workspace.css.ts +140 -0
  34. package/src/web/client/tree/Tree.tsx +43 -14
  35. package/src/web/client/tree/tree.model.ts +19 -0
  36. package/src/web/client/workspace.ts +19 -1
  37. package/src/web/server/backup.ts +59 -0
  38. package/src/web/server/routes.ts +45 -3
  39. package/src/web/server/server.ts +5 -0
  40. package/src/web/server/workspace-state.ts +121 -0
  41. package/src/web/shared/layout.ts +5 -6
  42. package/src/web/shared/preferences.ts +76 -0
  43. package/src/web/shared/themes.ts +92 -0
  44. package/src/web/shared/workspace.ts +310 -0
  45. package/src/web/client/selection.storage.ts +0 -69
  46. package/src/web/client/shell/Columns.tsx +0 -139
  47. package/src/web/client/shell/Divider.tsx +0 -15
  48. package/src/web/client/shell/Header.tsx +0 -119
  49. package/src/web/client/shell/drag.model.ts +0 -35
  50. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,5 +1,6 @@
1
1
  /** The vault and repository tree column. */
2
2
 
3
+ import { NOTE_DRAG_TYPE } from "../shell/shell.model";
3
4
  import { useLayoutEffect, useRef, useState } from "preact/hooks";
4
5
  import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
5
6
  import { fetchJson } from "../api.dom";
@@ -20,7 +21,6 @@ import {
20
21
  deleteNeedsConfirmation,
21
22
  depthVar,
22
23
  dropFolder,
23
- expand,
24
24
  initialTreeView,
25
25
  newFolderParent,
26
26
  newFolderPath,
@@ -33,6 +33,8 @@ import {
33
33
  rowCountLabel,
34
34
  rowViews,
35
35
  rowsFor,
36
+ revealFolder,
37
+ affectedNoteSlugs,
36
38
  setQuery,
37
39
  toggleExpanded,
38
40
  toggleInternals,
@@ -44,12 +46,12 @@ export interface TreeProps {
44
46
  graph: GraphPayload | null;
45
47
  selectedId: string | null;
46
48
  recentIds: ReadonlySet<string>;
47
- onSelect: (id: string) => void;
49
+ onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
48
50
  /**
49
51
  * Ask before a mutation that would invalidate an open draft. `false` means
50
52
  * keep editing: the mutation must not run.
51
53
  */
52
- onMutate: () => boolean;
54
+ onMutate: (slugs: readonly string[]) => boolean;
53
55
  onRefresh: () => void;
54
56
  now: number;
55
57
  }
@@ -74,7 +76,7 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
74
76
  );
75
77
  }
76
78
 
77
- function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: () => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
79
+ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
78
80
  const folder = dropFolder(view.id);
79
81
  return (
80
82
  <li
@@ -88,10 +90,10 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
88
90
  aria-selected={view.selected}
89
91
  aria-expanded={view.hasKids ? view.expanded : undefined}
90
92
  style={depthVar(view.depth)}
91
- onClick={onSelect}
93
+ onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}
92
94
  draggable={view.id.startsWith("note:")}
93
95
  aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
94
- onDragStart={(event) => event.dataTransfer?.setData("text/plain", view.id)}
96
+ onDragStart={(event) => { event.dataTransfer?.setData("text/plain", view.id); event.dataTransfer?.setData(NOTE_DRAG_TYPE, view.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; }}
95
97
  onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
96
98
  onDrop={folder === undefined ? undefined : (event) => { event.preventDefault(); onDrop(event.dataTransfer?.getData("text/plain") ?? ""); }}
97
99
  onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); onMenu(event.clientX, event.clientY); }}
@@ -161,6 +163,14 @@ export function Tree(props: TreeProps) {
161
163
  * what makes a mis-click cheap.
162
164
  */
163
165
  const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number; armed?: boolean } | null>(null);
166
+ const root = useRef<HTMLDivElement | null>(null);
167
+ useLayoutEffect(() => {
168
+ if (menu !== null) root.current?.querySelector<HTMLButtonElement>(".weave-menu button")?.focus();
169
+ }, [menu?.id]);
170
+ const closeMenu = (): void => {
171
+ setMenu(null);
172
+ root.current?.querySelector<HTMLButtonElement>(".weave-tree-actions")?.focus();
173
+ };
164
174
  const [editing, setEditing] = useState<{ id: string; label: string; value: string } | null>(null);
165
175
  /**
166
176
  * The parent path a pending "New folder" will be created under, or `null`.
@@ -190,15 +200,16 @@ export function Tree(props: TreeProps) {
190
200
  // gesture `rename` already uses, and it shows *where* the folder will
191
201
  // land, which a prompt cannot.
192
202
  const parent = newFolderParent(target);
193
- if (parent !== "") setState((current) => expand(current, `vfolder:${parent}`));
203
+ setState((current) => revealFolder(current, parent));
194
204
  setDraft(parent);
195
205
  setEditing({ id: DRAFT_FOLDER_ID, label: "", value: "" });
196
206
  } else if (action === "rename") {
207
+ setState((current) => revealFolder(current, target.path.split("/").slice(0, -1).join("/")));
197
208
  setEditing({ id: menu.id, label: menu.label, value: menu.label });
198
209
  } else if (target.type !== "vault") {
199
210
  // Before the request: asking after a delete is asking about a file that
200
211
  // is already gone, and "keep editing" would strand the draft on a 404.
201
- if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
212
+ if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
202
213
  await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
203
214
  }
204
215
  };
@@ -247,24 +258,32 @@ export function Tree(props: TreeProps) {
247
258
  // is the least useful moment to ask. Renaming back is one more rename.
248
259
  // An open draft is a different question; `onMutate` asks that one.
249
260
  if (target !== null && name && name !== current.label) {
250
- if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
261
+ if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
251
262
  void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
252
263
  }
253
264
  };
254
265
 
255
266
  return (
256
- <div class="weave-tree" onKeyDown={(event) => {
267
+ <div class="weave-tree" ref={root} onKeyDown={(event) => {
268
+ if (menu !== null && event.key === "Escape") {
269
+ event.preventDefault(); event.stopPropagation(); closeMenu(); return;
270
+ }
257
271
  const target = event.target as KeyTarget;
258
272
  const next = treeKey(rows, state, props.selectedId, event.key, isTextEntry(target?.tagName ?? null, target?.isContentEditable === true));
259
273
  if (!next.handled) return;
260
274
  event.preventDefault();
261
275
  setState(next.state);
262
- if (next.selectedId !== null) props.onSelect(next.selectedId);
276
+ if (next.selectedId !== null) props.onSelect(next.selectedId, false, true);
263
277
  }}>
264
278
  <div class="weave-tree-controls">
265
279
  <input type="search" class="weave-filter" value={state.query} placeholder={FILTER_PLACEHOLDER} aria-label={FILTER_LABEL} title={FILTER_HINT} onInput={(event) => setState(setQuery(state, event.currentTarget.value))} />
266
280
  <button type="button" class="weave-chip" title={provenanceHint(state.provFilter)} onClick={() => setState(cycleProvenance(state))}>◧ {provenanceLabel(state.provFilter)}</button>
267
281
  <button type="button" class="weave-chip" title={internalsHint(state.showInternals)} onClick={() => setState(toggleInternals(state))}>◧ {internalsLabel(state.showInternals)}</button>
282
+ <button type="button" class="weave-chip weave-tree-actions" aria-label="File actions" title="File actions for the selected item" onClick={(event) => {
283
+ const id = props.selectedId !== null && mutableTreeRow(props.selectedId) !== null ? props.selectedId : "vault";
284
+ const at = event.currentTarget.getBoundingClientRect();
285
+ setMenu({ id, label: props.graph?.model.nodes.find((node) => node.id === id)?.label ?? "Vault", x: at.left, y: at.bottom });
286
+ }}>···</button>
268
287
  </div>
269
288
  {empty === null ? (
270
289
  <ul
@@ -278,7 +297,7 @@ export function Tree(props: TreeProps) {
278
297
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
279
298
  }}
280
299
  >
281
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { if (dragged === props.selectedId && !props.onMutate()) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
300
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
282
301
  </ul>
283
302
  ) : <p class="weave-tree-empty">{empty}</p>}
284
303
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -286,8 +305,18 @@ export function Tree(props: TreeProps) {
286
305
  const target = mutableTreeRow(menu.id)!;
287
306
  return (
288
307
  <>
289
- <button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={() => setMenu(null)} />
290
- <div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }}>
308
+ <button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={closeMenu} />
309
+ <div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }} onKeyDown={(event) => {
310
+ if (event.key === "Escape") { event.preventDefault(); closeMenu(); }
311
+ else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
312
+ event.preventDefault();
313
+ const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button")];
314
+ const index = items.indexOf(document.activeElement as HTMLButtonElement);
315
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
316
+ items[next]?.focus();
317
+ }
318
+ event.stopPropagation();
319
+ }}>
291
320
  <button type="button" role="menuitem" onClick={() => void act("newFolder")}>New folder…</button>
292
321
  {target.type !== "vault" ? (
293
322
  <>
@@ -74,6 +74,17 @@ export function initialTreeView(roots: readonly string[] = ["vault", "repository
74
74
  return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
75
75
  }
76
76
 
77
+ /** Make an inline file operation visible even when its destination was filtered or collapsed. */
78
+ export function revealFolder(state: TreeViewState, path: string): TreeViewState {
79
+ const expanded = new Set(state.expanded).add("vault");
80
+ let parent = "";
81
+ for (const part of path ? path.split("/") : []) {
82
+ parent = parent ? `${parent}/${part}` : part;
83
+ expanded.add(`vfolder:${parent}`);
84
+ }
85
+ return { ...state, expanded, query: "", provFilter: null };
86
+ }
87
+
77
88
  // --- reassembling the wire payload -------------------------------------------------
78
89
 
79
90
  /**
@@ -224,6 +235,14 @@ export function mutableTreeRow(id: string): { type: "note" | "folder" | "vault";
224
235
  return null;
225
236
  }
226
237
 
238
+ /** Note slugs a note or folder mutation can invalidate. */
239
+ export function affectedNoteSlugs(graph: GraphPayload | null, target: { type: "note" | "folder" | "vault"; path: string }): string[] {
240
+ if (target.type === "note") return [target.path];
241
+ if (target.type !== "folder" || graph === null) return [];
242
+ const prefix = `note:${target.path}/`;
243
+ return graph.model.nodes.filter((node) => node.kind === "note" && node.id.startsWith(prefix)).map((node) => node.id.slice(5));
244
+ }
245
+
227
246
  export function deletesSelection(selectedId: string | null, target: { type: "note" | "folder" | "vault"; path: string }): boolean {
228
247
  if (selectedId === null) return false;
229
248
  if (target.type === "note") return selectedId === `note:${target.path}`;
@@ -1,6 +1,6 @@
1
1
  /** Fetching and polling for the browser workspace. */
2
2
 
3
- import type { GraphPayload } from "../shared/wire";
3
+ import type { GraphPayload, NotePayload } from "../shared/wire";
4
4
  import type { ApiResult, FetchLike } from "./api";
5
5
  import { fetchGraph, fetchNote } from "./api";
6
6
  import type { WorkspaceState } from "./state";
@@ -115,3 +115,21 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
115
115
  },
116
116
  };
117
117
  }
118
+
119
+ /** Per-document loading: retry failures, and drop completions after a tab leaves. */
120
+ export function watchNote(fetch: FetchLike, slug: string, notify: (result: ApiResult<NotePayload>) => void,
121
+ defer: (fn: () => void, ms: number) => () => void = (fn, ms) => {
122
+ const timer = setTimeout(fn, ms); return () => clearTimeout(timer);
123
+ }): () => void {
124
+ let stopped = false;
125
+ let cancel: (() => void) | null = null;
126
+ const load = async (): Promise<void> => {
127
+ if (stopped) return;
128
+ const result = await fetchNote(fetch, slug);
129
+ if (stopped) return;
130
+ notify(result);
131
+ if (!result.ok) cancel = defer(() => void load(), POLL_MS);
132
+ };
133
+ void load();
134
+ return () => { stopped = true; cancel?.(); };
135
+ }
@@ -0,0 +1,59 @@
1
+ /** Create a complete archive before offering a download; originals are read only. */
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdtemp, open, readdir, realpath, rm, stat } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { Zip, ZipDeflate } from "fflate";
7
+
8
+ export async function createVaultBackup(vaultRoot: string): Promise<{ path: string; dispose: () => Promise<void> }> {
9
+ const directory = await mkdtemp(join(tmpdir(), "pi-weave-backup-"));
10
+ const path = join(directory, "vault.zip");
11
+ const dispose = (): Promise<void> => rm(directory, { recursive: true, force: true });
12
+ try {
13
+ const output = await open(path, "w");
14
+ try {
15
+ let pending = Promise.resolve();
16
+ let failure: Error | null = null;
17
+ let entries = 0;
18
+ const zip = new Zip((error, chunk) => {
19
+ if (error) { failure = error; return; }
20
+ pending = pending.then(() => output.writeFile(chunk));
21
+ });
22
+ const flush = async (): Promise<void> => { await pending; if (failure) throw failure; };
23
+ const walk = async (directory: string, prefix: string, ancestors: ReadonlySet<string>): Promise<void> => {
24
+ const resolved = await realpath(directory);
25
+ if (ancestors.has(resolved)) throw new Error(`Symbolic-link cycle in vault: ${prefix}`);
26
+ const next = new Set([...ancestors, resolved]);
27
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
28
+ // ZIP readers on Windows treat backslashes as path separators.
29
+ if (entry.name.includes("\\")) throw new Error(`Unsupported backup filename: ${entry.name}`);
30
+ const source = join(directory, entry.name);
31
+ const info = await stat(source);
32
+ const name = prefix + entry.name + (info.isDirectory() ? "/" : "");
33
+ if (!info.isDirectory() && !info.isFile()) throw new Error(`Unsupported vault entry: ${name}`);
34
+ if (++entries > 65535 || info.isFile() && info.size > 0xffffffff) throw new Error("Vault exceeds ZIP format limits");
35
+ const file = new ZipDeflate(name, { level: 1 });
36
+ file.mtime = new Date(Math.max(new Date(1980, 0, 1).getTime(), Math.min(new Date(2099, 11, 31).getTime(), info.mtime.getTime())));
37
+ zip.add(file);
38
+ await flush();
39
+ if (info.isDirectory()) {
40
+ file.push(new Uint8Array(), true);
41
+ await flush();
42
+ await walk(source, name, next);
43
+ } else {
44
+ for await (const chunk of createReadStream(source)) {
45
+ file.push(chunk as Uint8Array);
46
+ await flush();
47
+ }
48
+ file.push(new Uint8Array(), true);
49
+ await flush();
50
+ }
51
+ }
52
+ };
53
+ await walk(vaultRoot, "", new Set());
54
+ zip.end();
55
+ await flush();
56
+ } finally { await output.close(); }
57
+ return { path, dispose };
58
+ } catch (error) { await dispose(); throw error; }
59
+ }
@@ -43,7 +43,10 @@
43
43
  * `resolveNotePath` (via `getNote`) rejects unsafe slugs.
44
44
  */
45
45
 
46
- import { createHash } from "node:crypto";
46
+ import { createHash, randomUUID } from "node:crypto";
47
+ import { createReadStream } from "node:fs";
48
+ import { pipeline } from "node:stream/promises";
49
+ import { createVaultBackup } from "./backup";
47
50
  import { readFile } from "node:fs/promises";
48
51
  import type { IncomingMessage, ServerResponse } from "node:http";
49
52
  import type { WorkspaceSnapshot } from "../../core/cache/workspace";
@@ -65,6 +68,8 @@ import type {
65
68
  } from "../shared/wire";
66
69
  import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
67
70
  import { renderPage } from "./page";
71
+ import { isWorkspaceViewId, type WorkspaceStateStore } from "./workspace-state";
72
+ import { parseWorkspaceLayout } from "../shared/workspace";
68
73
  import type { RequestFacts, SecurityPolicy } from "./security";
69
74
  import { requestFacts } from "./security";
70
75
 
@@ -78,6 +83,7 @@ export interface RouteDeps {
78
83
  bundlePath: string;
79
84
  /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
80
85
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
86
+ workspaceState: WorkspaceStateStore;
81
87
  }
82
88
 
83
89
  const JSON_TYPE = "application/json; charset=utf-8";
@@ -160,8 +166,9 @@ export function parseTarget(url: string): Target {
160
166
  * the caller has exactly one failure branch.
161
167
  */
162
168
  export const MAX_BODY_BYTES = 64 * 1024;
169
+ const MAX_WORKSPACE_STATE_BODY_BYTES = 256 * 1024;
163
170
 
164
- export async function readJsonBody(req: IncomingMessage): Promise<unknown | null> {
171
+ export async function readJsonBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise<unknown | null> {
165
172
  const chunks: Buffer[] = [];
166
173
  let size = 0;
167
174
  // `IncomingMessage` yields Buffers unless `setEncoding` was called, and we
@@ -169,7 +176,7 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null
169
176
  // pretending to be defensive.
170
177
  for await (const chunk of req as AsyncIterable<Buffer>) {
171
178
  size += chunk.length;
172
- if (size > MAX_BODY_BYTES) return null;
179
+ if (size > maxBytes) return null;
173
180
  chunks.push(chunk);
174
181
  }
175
182
  if (chunks.length === 0) return null;
@@ -375,6 +382,18 @@ async function route(
375
382
  if (method === "GET" && path === "/") return sendShell(deps, res);
376
383
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
377
384
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
385
+ if (method === "GET" && path === "/api/backup") {
386
+ const backup = await createVaultBackup(deps.vaultRoot);
387
+ try {
388
+ res.writeHead(200, { ...baseHeaders(), "content-type": "application/zip", "content-disposition": `attachment; filename="weave-vault-${new Date().toISOString().slice(0, 10)}.zip"`, "cache-control": "no-store" });
389
+ await pipeline(createReadStream(backup.path), res);
390
+ } finally { await backup.dispose(); }
391
+ return;
392
+ }
393
+ if (method === "GET" && path === "/api/workspace-state") {
394
+ return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest(), info: { vaultRoot: deps.vaultRoot, version: (JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")) as { version: string }).version } }, { "cache-control": "no-store" });
395
+ }
396
+ if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
378
397
  if (path.startsWith("/api/folder/")) {
379
398
  const handled = await routeFolder(deps, method, path.slice("/api/folder/".length), req, res);
380
399
  if (handled) return;
@@ -396,6 +415,29 @@ async function route(
396
415
 
397
416
  // --- handlers ----------------------------------------------------------------
398
417
 
418
+ async function saveWorkspaceState(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
419
+ const body = await readJsonBody(req, MAX_WORKSPACE_STATE_BODY_BYTES);
420
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
421
+ sendJson(res, 400, { error: "Expected a workspace state object" });
422
+ return;
423
+ }
424
+ const record = body as Record<string, unknown>;
425
+ if (
426
+ Object.keys(record).length !== 2 || !Object.hasOwn(record, "viewId") || !Object.hasOwn(record, "layout") ||
427
+ !isWorkspaceViewId(record.viewId)
428
+ ) {
429
+ sendJson(res, 400, { error: "Expected a UUID viewId and a valid layout" });
430
+ return;
431
+ }
432
+ const layout = parseWorkspaceLayout(record.layout);
433
+ if (layout === null) {
434
+ sendJson(res, 400, { error: "Invalid workspace layout" });
435
+ return;
436
+ }
437
+ await deps.workspaceState.write(record.viewId, layout);
438
+ sendJson(res, 200, { ok: true }, { "cache-control": "no-store" });
439
+ }
440
+
399
441
  function sendShell(deps: RouteDeps, res: ServerResponse): void {
400
442
  const page = renderPage({
401
443
  bootstrap: { cwd: deps.cwd },
@@ -19,6 +19,7 @@ import { WorkspaceCache } from "../../core/cache/workspace";
19
19
  import { resolveVaultRoot } from "../../core/paths";
20
20
  import { handleRequest, type RouteDeps } from "./routes";
21
21
  import { createSecurityPolicy, type SecurityPolicy } from "./security";
22
+ import { createWorkspaceStateStore, defaultWorkspaceStateDir } from "./workspace-state";
22
23
 
23
24
  /**
24
25
  * The committed bundle, resolved relative to this module rather than to
@@ -41,6 +42,8 @@ export interface StartWorkspaceServerOptions {
41
42
  cookieName?: string | undefined;
42
43
  /** Absolute path to the client bundle. Defaults to the committed one. */
43
44
  bundlePath?: string | undefined;
45
+ /** Presentation snapshot directory. Defaults to the user's pi-weave config directory. */
46
+ presentationStateDir?: string | undefined;
44
47
  /** Test seam for `POST /api/open`. */
45
48
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
46
49
  }
@@ -61,6 +64,7 @@ export interface WorkspaceServer {
61
64
  export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): Promise<WorkspaceServer> {
62
65
  const vaultRoot = opts.vaultRoot ?? resolveVaultRoot();
63
66
  const cache = opts.cache ?? new WorkspaceCache({ cwd: opts.cwd, vaultRoot });
67
+ const workspaceState = await createWorkspaceStateStore(opts.cwd, vaultRoot, opts.presentationStateDir ?? defaultWorkspaceStateDir());
64
68
  // `deps` is assembled before `listen` because the request handler closes
65
69
  // over it, but the security policy needs the bound port — so the policy
66
70
  // slot is filled after binding and the handler reads it through the
@@ -104,6 +108,7 @@ export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): P
104
108
  security,
105
109
  bundlePath: opts.bundlePath ?? defaultBundlePath(),
106
110
  ...(opts.openNote !== undefined ? { openNote: opts.openNote } : {}),
111
+ workspaceState,
107
112
  };
108
113
 
109
114
  return {
@@ -0,0 +1,121 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { findGitRoot } from "../../core/git";
6
+ import { withMutationQueue } from "../../core/mutex";
7
+ import { parseWorkspaceLayout, type WorkspaceLayout } from "../shared/workspace";
8
+
9
+ const MAX_SNAPSHOTS = 8;
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+
12
+ export function isWorkspaceViewId(value: unknown): value is string {
13
+ return typeof value === "string" && UUID.test(value);
14
+ }
15
+
16
+ interface Snapshot {
17
+ viewId: string;
18
+ savedAt: number;
19
+ layout: WorkspaceLayout;
20
+ }
21
+
22
+ interface SnapshotFile {
23
+ version: 1;
24
+ viewId: string;
25
+ layout: WorkspaceLayout;
26
+ }
27
+
28
+ export interface WorkspaceStateStore {
29
+ readLatest(): Promise<WorkspaceLayout | null>;
30
+ write(viewId: string, layout: WorkspaceLayout): Promise<void>;
31
+ }
32
+
33
+ export function defaultWorkspaceStateDir(): string {
34
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "pi-weave", "presentation");
35
+ }
36
+
37
+ export function createWorkspaceStateStore(
38
+ cwd: string,
39
+ vaultRoot: string,
40
+ stateDir = defaultWorkspaceStateDir(),
41
+ ): WorkspaceStateStore {
42
+ let directoryPromise: Promise<string> | null = null;
43
+ const directory = (): Promise<string> => {
44
+ directoryPromise ??= (async () => {
45
+ const [vault, repo] = await Promise.all([canonical(vaultRoot), findGitRoot(cwd)]);
46
+ const workspaceRoot = await canonical(repo ?? cwd);
47
+ const id = createHash("sha256").update(JSON.stringify([vault, workspaceRoot])).digest("hex");
48
+ return join(stateDir, id);
49
+ })();
50
+ return directoryPromise;
51
+ };
52
+
53
+ return {
54
+ async readLatest() {
55
+ return (await readSnapshots(await directory()))[0]?.layout ?? null;
56
+ },
57
+ async write(viewId, layout) {
58
+ if (!isWorkspaceViewId(viewId)) throw new Error("viewId must be a UUID");
59
+ const dir = await directory();
60
+ const file = join(dir, `${viewId}.json`);
61
+ await withMutationQueue(file, async () => {
62
+ await writeSnapshot(dir, file, { version: 1, viewId, layout });
63
+ });
64
+ },
65
+ };
66
+ }
67
+
68
+ async function canonical(path: string): Promise<string> {
69
+ const absolute = resolve(path);
70
+ try {
71
+ return await fs.realpath(absolute);
72
+ } catch {
73
+ return absolute;
74
+ }
75
+ }
76
+
77
+ async function readSnapshots(directory: string): Promise<Snapshot[]> {
78
+ let names: string[];
79
+ try {
80
+ names = await fs.readdir(directory);
81
+ } catch (error) {
82
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
83
+ throw error;
84
+ }
85
+ const snapshots = await Promise.all(names.filter((name) => UUID.test(name.slice(0, -5)) && name.endsWith(".json")).map(async (name) => {
86
+ const file = join(directory, name);
87
+ try {
88
+ const [text, info] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
89
+ const raw = JSON.parse(text) as SnapshotFile;
90
+ const layout = raw.version === 1 && raw.viewId === name.slice(0, -5) ? parseWorkspaceLayout(raw.layout) : null;
91
+ return layout ? { viewId: raw.viewId, savedAt: info.mtimeMs, layout } : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }));
96
+ return snapshots.filter((item): item is Snapshot => item !== null).sort((a, b) => b.savedAt - a.savedAt).slice(0, MAX_SNAPSHOTS);
97
+ }
98
+
99
+ async function writeSnapshot(directory: string, file: string, state: SnapshotFile): Promise<void> {
100
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
101
+ const temporary = `${file}.${randomUUID()}.tmp`;
102
+ try {
103
+ await fs.writeFile(temporary, JSON.stringify(state), { encoding: "utf8", flag: "wx", mode: 0o600 });
104
+ await fs.rename(temporary, file);
105
+ const entries = await Promise.all((await fs.readdir(directory)).filter((name) => name.endsWith(".json")).map(async (name) => {
106
+ const path = join(directory, name);
107
+ try {
108
+ return { path, mtime: (await fs.stat(path)).mtimeMs };
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
111
+ throw error;
112
+ }
113
+ }));
114
+ const existing = entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
115
+ existing.sort((a, b) => b.mtime - a.mtime);
116
+ await Promise.all(existing.slice(MAX_SNAPSHOTS).map(({ path }) => fs.rm(path, { force: true })));
117
+ } catch (error) {
118
+ await fs.rm(temporary, { force: true });
119
+ throw error;
120
+ }
121
+ }
@@ -41,14 +41,13 @@ const ALPHA_MIN = 0.001;
41
41
  * Mutable because picking these numbers is an act of taste, not of
42
42
  * derivation: the difference between "one hairball" and "legible groups" is a
43
43
  * ratio between containment cohesion, charge and centre gravity that is far
44
- * easier to *see* than to reason about. The hidden tuner (`?sliders=1`, see
45
- * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) writes here
46
- * and re-lays out live, so a human can find the values by eye and they then
44
+ * easier to *see* than to reason about. The settings sliders (see
45
+ * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) update this record
46
+ * and re-run the layout live, so a human can find the values by eye and they then
47
47
  * get frozen back into {@link FORCE_DEFAULTS}.
48
48
  *
49
- * Determinism is untouched: every production and test path only ever *reads*
50
- * this, so `computeLayout(model, { seed })` stays byte-identical. The tuner is
51
- * the one writer, and it is unreachable without the query flag.
49
+ * `computeLayout(model, { seed })` stays deterministic for a given set of forces.
50
+ * Settings are the production writer of this record.
52
51
  *
53
52
  * ponytail: module-level mutable state, fine at one graph per page — thread it
54
53
  * as a `LayoutOptions` field if a second concurrent consumer ever appears.
@@ -0,0 +1,76 @@
1
+ /** Validated preferences, stored alongside the workspace's existing snapshots. */
2
+ import { FORCE_DEFAULTS } from "./layout";
3
+ import type { ForceConstants } from "./layout";
4
+ import { ACCENTS, THEMES, isThemeChoice } from "./themes";
5
+ import type { AccentChoice, PaletteChoice } from "./themes";
6
+
7
+ /** One slider: which constant it drives, and the range it may drive it over. */
8
+ export interface SliderSpec {
9
+ readonly key: keyof ForceConstants;
10
+ readonly label: string;
11
+ /** One line under the label saying what moving it does. */
12
+ readonly hint: string;
13
+ readonly min: number;
14
+ readonly max: number;
15
+ readonly step: number;
16
+ }
17
+
18
+ /** Graph forces, ordered from group cohesion to repulsion and centering. */
19
+ export const FORCE_SLIDERS: readonly SliderSpec[] = [
20
+ { key: "containsStrength", label: "Group cohesion", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
21
+ { key: "containsRest", label: "Group radius", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
22
+ { key: "relationStrength", label: "Link strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
23
+ { key: "relationDistance", label: "Link distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
24
+ { key: "charge", label: "Repulsion", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
25
+ { key: "chargeMax", label: "Repulsion range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
26
+ { key: "center", label: "Center gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
27
+ ];
28
+
29
+
30
+ export interface Preferences {
31
+ readonly lightTheme: PaletteChoice;
32
+ readonly darkTheme: PaletteChoice;
33
+ readonly accent: AccentChoice;
34
+ readonly fontSize: number;
35
+ readonly readable: boolean;
36
+ readonly spellcheck: boolean;
37
+ readonly defaultEdit: boolean;
38
+ readonly startup: "restore" | "empty";
39
+ readonly focusNewTabs: boolean;
40
+ readonly groupColors: boolean;
41
+ readonly forces: ForceConstants;
42
+ }
43
+
44
+ export const DEFAULT_PREFERENCES: Preferences = {
45
+ lightTheme: "light", darkTheme: "dark", accent: "theme", fontSize: 14, readable: true, spellcheck: true, defaultEdit: false,
46
+ startup: "restore", focusNewTabs: true, groupColors: true, forces: { ...FORCE_DEFAULTS },
47
+ };
48
+
49
+ function record(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ export function parsePreferences(input: unknown): Preferences | null {
54
+ if (!record(input)) return null;
55
+ const legacy = !Object.hasOwn(input, "lightTheme") && !Object.hasOwn(input, "darkTheme");
56
+ const value = legacy ? { ...input, lightTheme: "light", darkTheme: "dark" } : input;
57
+ if (Object.keys(value).length !== Object.keys(DEFAULT_PREFERENCES).length) return null;
58
+ for (const scheme of ["light", "dark"] as const) {
59
+ const theme = value[`${scheme}Theme`];
60
+ if (!isThemeChoice(theme) || theme === "system" || THEMES[theme].scheme !== scheme) return null;
61
+ }
62
+ if (value["accent"] !== "theme" && (typeof value["accent"] !== "string" || !Object.hasOwn(ACCENTS, value["accent"]))) return null;
63
+ if (typeof value["fontSize"] !== "number" || !Number.isInteger(value["fontSize"]) || value["fontSize"] < 12 || value["fontSize"] > 22) return null;
64
+ for (const key of ["readable", "spellcheck", "defaultEdit", "focusNewTabs", "groupColors"] as const) {
65
+ if (typeof value[key] !== "boolean") return null;
66
+ }
67
+ if (value["startup"] !== "restore" && value["startup"] !== "empty") return null;
68
+ const forces = value["forces"];
69
+ if (!record(forces) || Object.keys(forces).length !== FORCE_SLIDERS.length) return null;
70
+ for (const { key, min, max } of FORCE_SLIDERS) {
71
+ const force = forces[key];
72
+ if (typeof force !== "number" || !Number.isFinite(force) || force < min || force > max) return null;
73
+ }
74
+ return value as unknown as Preferences;
75
+ }
76
+