pi-weave 0.2.4 → 0.3.1

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 (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -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
+ }
@@ -0,0 +1,270 @@
1
+ /** Serializable tab and pane state shared by the browser and persistence API. */
2
+
3
+ export interface WorkspaceTab {
4
+ readonly id: string;
5
+ readonly kind: "document" | "graph";
6
+ readonly history: readonly (string | null)[];
7
+ readonly cursor: number;
8
+ readonly scroll: number;
9
+ }
10
+
11
+ export interface Pane {
12
+ readonly id: string;
13
+ readonly tabs: readonly WorkspaceTab[];
14
+ readonly activeTab: string;
15
+ readonly lastDocumentTab?: string;
16
+ }
17
+
18
+ export interface WorkspaceLayout {
19
+ readonly version: 1;
20
+ readonly panes: readonly Pane[];
21
+ readonly activePane: string;
22
+ readonly split: "right" | "down";
23
+ readonly ratio: number;
24
+ readonly treeVisible: boolean;
25
+ readonly contextVisible: boolean;
26
+ readonly treeWidth: number;
27
+ readonly contextWidth: number;
28
+ readonly theme: "system" | "light" | "dark";
29
+ }
30
+
31
+ const MAX_TABS = 40;
32
+ const MAX_HISTORY = 100;
33
+ const MAX_SCROLL = 1_000_000_000;
34
+
35
+ const emptyDocument = (id: string): WorkspaceTab => ({ id, kind: "document", history: [null], cursor: 0, scroll: 0 });
36
+
37
+ export function initialLayout(): WorkspaceLayout {
38
+ return {
39
+ version: 1,
40
+ panes: [{ id: "pane-1", tabs: [emptyDocument("tab-1")], activeTab: "tab-1" }],
41
+ activePane: "pane-1",
42
+ split: "right",
43
+ ratio: 0.5,
44
+ treeVisible: true,
45
+ contextVisible: true,
46
+ treeWidth: 240,
47
+ contextWidth: 240,
48
+ theme: "system",
49
+ };
50
+ }
51
+
52
+ export function activePane(layout: WorkspaceLayout): Pane {
53
+ return layout.panes.find((pane) => pane.id === layout.activePane)!;
54
+ }
55
+
56
+ export function activeTab(layout: WorkspaceLayout): WorkspaceTab {
57
+ const pane = activePane(layout);
58
+ return pane.tabs.find((tab) => tab.id === pane.activeTab)!;
59
+ }
60
+
61
+ export function tabSelection(tab: WorkspaceTab): string | null {
62
+ return tab.kind === "document" ? tab.history[tab.cursor] ?? null : null;
63
+ }
64
+
65
+ function nextId(layout: WorkspaceLayout, prefix: "pane" | "tab"): string {
66
+ const ids = new Set(layout.panes.flatMap((pane) => [pane.id, ...pane.tabs.map((tab) => tab.id)]));
67
+ let n = 1;
68
+ while (ids.has(`${prefix}-${n}`)) n++;
69
+ return `${prefix}-${n}`;
70
+ }
71
+
72
+ function setActive(layout: WorkspaceLayout, pane: Pane, tab: WorkspaceTab): WorkspaceLayout {
73
+ return {
74
+ ...layout,
75
+ activePane: pane.id,
76
+ panes: layout.panes.map((item) => item.id === pane.id
77
+ ? rememberDocument({ ...pane, activeTab: tab.id, tabs: pane.tabs.map((current) => current.id === tab.id ? tab : current) })
78
+ : item),
79
+ };
80
+ }
81
+
82
+ function documentTab(pane: Pane): WorkspaceTab | undefined {
83
+ return pane.tabs.find((tab) => tab.id === pane.lastDocumentTab && tab.kind === "document")
84
+ ?? [...pane.tabs].reverse().find((tab) => tab.kind === "document");
85
+ }
86
+
87
+ function rememberDocument(pane: Pane): Pane {
88
+ const { lastDocumentTab: _previous, ...rest } = pane;
89
+ const tab = pane.tabs.find((tab) => tab.id === pane.activeTab && tab.kind === "document") ?? documentTab(pane);
90
+ return tab ? { ...rest, lastDocumentTab: tab.id } : rest;
91
+ }
92
+
93
+ /** Focus reading/editing without navigating away from the active document. */
94
+ export function focusDocument(layout: WorkspaceLayout): WorkspaceLayout {
95
+ if (activeTab(layout).kind === "document") return layout;
96
+ const pane = activePane(layout);
97
+ const tab = documentTab(pane);
98
+ return tab ? setActive(layout, pane, tab) : openDocument(layout, null, { newTab: true });
99
+ }
100
+
101
+ function findTab(layout: WorkspaceLayout, paneId: string, tabId: string): { pane: Pane; tab: WorkspaceTab } | null {
102
+ const pane = layout.panes.find((item) => item.id === paneId);
103
+ const tab = pane?.tabs.find((item) => item.id === tabId);
104
+ return pane && tab ? { pane, tab } : null;
105
+ }
106
+
107
+ function appendTab(layout: WorkspaceLayout, pane: Pane, tab: WorkspaceTab): WorkspaceLayout {
108
+ if (layout.panes.reduce((count, item) => count + item.tabs.length, 0) >= MAX_TABS) return layout;
109
+ return setActive(layout, { ...pane, tabs: [...pane.tabs, tab] }, tab);
110
+ }
111
+
112
+ function navigate(tab: WorkspaceTab, id: string | null): WorkspaceTab {
113
+ const history = tab.history.slice(0, tab.cursor + 1);
114
+ if (history.at(-1) === id) return tab;
115
+ history.push(id);
116
+ if (history.length > MAX_HISTORY) history.shift();
117
+ return { ...tab, history, cursor: history.length - 1, scroll: 0 };
118
+ }
119
+
120
+ export function openDocument(layout: WorkspaceLayout, id: string | null, options: { newTab?: boolean; paneId?: string } = {}): WorkspaceLayout {
121
+ let pane = layout.panes.find((item) => item.id === (options.paneId ?? layout.activePane));
122
+ if (!pane) return layout;
123
+ const activeTabId = pane.activeTab;
124
+ let tab = pane.tabs.find((item) => item.id === activeTabId);
125
+ if (tab?.kind === "graph") {
126
+ tab = documentTab(pane);
127
+ if (tab) pane = { ...pane, activeTab: tab.id };
128
+ else if (!options.newTab) options = { ...options, newTab: true };
129
+ }
130
+ if (options.newTab) return appendTab(layout, pane, navigate(emptyDocument(nextId(layout, "tab")), id));
131
+ if (!tab) return appendTab(layout, pane, navigate(emptyDocument(nextId(layout, "tab")), id));
132
+ const next = navigate(tab, id);
133
+ if (next === tab && pane.id === layout.activePane && activeTabId === tab.id) return layout;
134
+ return setActive(layout, pane, next);
135
+ }
136
+
137
+ export function openGraph(layout: WorkspaceLayout): WorkspaceLayout {
138
+ const located = layout.panes.flatMap((pane) => pane.tabs.filter((tab) => tab.kind === "graph").map((tab) => ({ pane, tab })))[0];
139
+ if (located) return located.pane.id === layout.activePane && located.pane.activeTab === located.tab.id
140
+ ? layout
141
+ : setActive(layout, located.pane, located.tab);
142
+ const pane = activePane(layout);
143
+ return appendTab(layout, pane, { id: nextId(layout, "tab"), kind: "graph", history: [null], cursor: 0, scroll: 0 });
144
+ }
145
+
146
+ export function activateTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
147
+ const found = findTab(layout, paneId, tabId);
148
+ return !found || (layout.activePane === paneId && found.pane.activeTab === tabId)
149
+ ? layout
150
+ : setActive(layout, found.pane, found.tab);
151
+ }
152
+
153
+ export function closeTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
154
+ const found = findTab(layout, paneId, tabId);
155
+ if (!found) return layout;
156
+ const { pane } = found;
157
+ if (pane.tabs.length === 1) {
158
+ const kept = layout.panes.find((item) => item.id !== paneId);
159
+ if (kept) return { ...layout, panes: [kept], activePane: kept.id };
160
+ const tab = emptyDocument(nextId(layout, "tab"));
161
+ return setActive(layout, { ...pane, tabs: [tab] }, tab);
162
+ }
163
+ const tabs = pane.tabs.filter((tab) => tab.id !== tabId);
164
+ const wasActive = pane.activeTab === tabId;
165
+ const neighbor = tabs[Math.max(0, pane.tabs.findIndex((tab) => tab.id === tabId) - 1)]!;
166
+ const updated = rememberDocument({ ...pane, tabs, activeTab: wasActive ? neighbor.id : pane.activeTab });
167
+ return { ...layout, activePane: wasActive ? paneId : layout.activePane, panes: layout.panes.map((item) => item.id === paneId ? updated : item) };
168
+ }
169
+
170
+ export function navigateTab(layout: WorkspaceLayout, paneId: string, delta: -1 | 1): WorkspaceLayout {
171
+ const pane = layout.panes.find((item) => item.id === paneId);
172
+ const tab = pane?.tabs.find((item) => item.id === pane.activeTab);
173
+ if (!pane || !tab || tab.kind !== "document") return layout;
174
+ const cursor = tab.cursor + delta;
175
+ if (cursor < 0 || cursor >= tab.history.length) return layout;
176
+ return setActive(layout, pane, { ...tab, cursor, scroll: 0 });
177
+ }
178
+
179
+ export function splitPane(layout: WorkspaceLayout, direction: "right" | "down"): WorkspaceLayout {
180
+ if (layout.panes.length === 2) return layout.split === direction ? layout : { ...layout, split: direction };
181
+ if (layout.panes.reduce((count, pane) => count + pane.tabs.length, 0) >= MAX_TABS) return layout;
182
+ const current = activeTab(layout);
183
+ const id = nextId(layout, "pane");
184
+ let tab = emptyDocument(nextId(layout, "tab"));
185
+ if (current.kind === "document") tab = { ...current, id: tab.id, history: [...current.history], scroll: current.scroll };
186
+ return { ...layout, panes: [...layout.panes, { id, tabs: [tab], activeTab: tab.id }], activePane: id, split: direction };
187
+ }
188
+
189
+ export function closePane(layout: WorkspaceLayout, paneId: string): WorkspaceLayout {
190
+ if (layout.panes.length !== 2 || !layout.panes.some((pane) => pane.id === paneId)) return layout;
191
+ const closed = layout.panes.find((pane) => pane.id === paneId)!;
192
+ const kept = layout.panes.find((pane) => pane.id !== paneId)!;
193
+ return { ...layout, panes: [{ ...kept, tabs: [...kept.tabs, ...closed.tabs] }], activePane: kept.id };
194
+ }
195
+
196
+ /** Accept a tab drop only in another existing pane. */
197
+ export function tabDropSource(layout: WorkspaceLayout, targetPane: string, tabId: string | null): string | null {
198
+ if (!layout.panes.some((pane) => pane.id === targetPane)) return null;
199
+ return layout.panes.find((pane) => pane.id !== targetPane && pane.tabs.some((tab) => tab.id === tabId))?.id ?? null;
200
+ }
201
+
202
+ export function moveTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
203
+ const found = findTab(layout, paneId, tabId);
204
+ if (!found) return layout;
205
+ const target = layout.panes.find((pane) => pane.id !== paneId) ?? { id: nextId(layout, "pane"), tabs: [], activeTab: "" };
206
+ const sourceTabs = found.pane.tabs.filter((tab) => tab.id !== tabId);
207
+ const destination = rememberDocument({ ...target, tabs: [...target.tabs, found.tab], activeTab: found.tab.id });
208
+ if (!sourceTabs.length && layout.panes.length === 2) return { ...layout, panes: [destination], activePane: target.id };
209
+ if (!sourceTabs.length && layout.panes.reduce((count, pane) => count + pane.tabs.length, 0) >= MAX_TABS) return layout;
210
+ const placeholder = sourceTabs.length ? null : emptyDocument(nextId(layout, "tab"));
211
+ const remaining = sourceTabs.length ? sourceTabs : [placeholder!];
212
+ const source = rememberDocument({ ...found.pane, tabs: remaining, activeTab: found.pane.activeTab === tabId ? remaining[Math.max(0, found.pane.tabs.findIndex((tab) => tab.id === tabId) - 1)]!.id : found.pane.activeTab });
213
+ return { ...layout, activePane: target.id, panes: layout.panes.length === 1
214
+ ? [source, destination]
215
+ : layout.panes.map((pane) => pane.id === source.id ? source : destination) };
216
+ }
217
+
218
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
219
+ return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
220
+ }
221
+
222
+ function record(value: unknown): value is Record<string, unknown> {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
224
+ }
225
+
226
+ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
227
+ return typeof value === "string" && value.length <= 2048 && (prefix === undefined
228
+ ? value.length > 0 && !/[\u0000-\u001f\u007f]/.test(value)
229
+ : new RegExp(`^${prefix}-[1-9][0-9]{0,8}$`).test(value));
230
+ }
231
+
232
+ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
233
+ if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme"])) return null;
234
+ if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
235
+ if (value["split"] !== "right" && value["split"] !== "down") return null;
236
+ if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
237
+ if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
238
+ if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
239
+ if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
240
+ if (value["theme"] !== "system" && value["theme"] !== "light" && value["theme"] !== "dark") return null;
241
+ const ids = new Set<string>();
242
+ let count = 0;
243
+ let graphs = 0;
244
+ const panes: Pane[] = [];
245
+ for (const rawPane of value["panes"]) {
246
+ if (!record(rawPane) || !exactKeys(rawPane, Object.hasOwn(rawPane, "lastDocumentTab") ? ["id", "tabs", "activeTab", "lastDocumentTab"] : ["id", "tabs", "activeTab"]) || !boundedId(rawPane["id"], "pane") || !Array.isArray(rawPane["tabs"]) || rawPane["tabs"].length < 1 || !boundedId(rawPane["activeTab"], "tab") || ids.has(rawPane["id"])) return null;
247
+ ids.add(rawPane["id"]);
248
+ const tabs: WorkspaceTab[] = [];
249
+ let activeFound = false;
250
+ for (const rawTab of rawPane["tabs"]) {
251
+ if (!record(rawTab) || !exactKeys(rawTab, ["id", "kind", "history", "cursor", "scroll"]) || !boundedId(rawTab["id"], "tab") || ids.has(rawTab["id"])) return null;
252
+ ids.add(rawTab["id"]);
253
+ if (rawTab["kind"] !== "document" && rawTab["kind"] !== "graph") return null;
254
+ const kind = rawTab["kind"] as WorkspaceTab["kind"];
255
+ if (!Array.isArray(rawTab["history"]) || rawTab["history"].length < 1 || rawTab["history"].length > MAX_HISTORY || !Number.isInteger(rawTab["cursor"]) || (rawTab["cursor"] as number) < 0 || (rawTab["cursor"] as number) >= rawTab["history"].length) return null;
256
+ if (rawTab["history"].some((id) => id !== null && !boundedId(id))) return null;
257
+ if (typeof rawTab["scroll"] !== "number" || !Number.isFinite(rawTab["scroll"]) || rawTab["scroll"] < 0 || rawTab["scroll"] > MAX_SCROLL) return null;
258
+ if (kind === "graph") graphs++;
259
+ count++;
260
+ if (rawTab["id"] === rawPane["activeTab"]) activeFound = true;
261
+ tabs.push({ id: rawTab["id"], kind, history: rawTab["history"] as (string | null)[], cursor: rawTab["cursor"] as number, scroll: rawTab["scroll"] });
262
+ }
263
+ if (!activeFound) return null;
264
+ const lastDocumentTab = rawPane["lastDocumentTab"];
265
+ if (Object.hasOwn(rawPane, "lastDocumentTab") && (!boundedId(lastDocumentTab, "tab") || !tabs.some((tab) => tab.id === lastDocumentTab && tab.kind === "document"))) return null;
266
+ panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
267
+ }
268
+ if (count > MAX_TABS || graphs > 1 || !panes.some((pane) => pane.id === value["activePane"])) return null;
269
+ return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], theme: value["theme"] };
270
+ }
@@ -1,69 +0,0 @@
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
- }
@@ -1,139 +0,0 @@
1
- /**
2
- * The resizable column grid and context rail. The shell supplies resolved
3
- * widths and only the columns allowed by the current responsive breakpoint.
4
- */
5
-
6
- import { useLayoutEffect, useRef } from "preact/hooks";
7
- import { Graph } from "../graph/Graph";
8
- import type { ColorScheme } from "../graph/graph.model";
9
- import type { PositionStorage } from "../graph/positions";
10
- import type { RendererFactory } from "../graph/renderer";
11
- import type { SchemeHost } from "../graph/scheme";
12
- import { Note } from "../note/Note";
13
- import { Tree } from "../tree/Tree";
14
- import type { GraphPayload, NotePayload } from "../../shared/wire";
15
- import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
16
- import { ContextRail } from "./ContextRail";
17
- import { emptyStateFor } from "./shell.model";
18
- import { Divider } from "./Divider";
19
-
20
- export interface ColumnsProps {
21
- resolved: readonly ResolvedColumn[];
22
- onDown: (divider: DividerId, clientX: number) => void;
23
- onMove: (clientX: number) => void;
24
- onUp: () => void;
25
- onKey: (divider: DividerId, key: string) => void;
26
- /** The §1.3 context bus, as the columns see it. */
27
- graph: GraphPayload | null;
28
- note: NotePayload | null;
29
- noteFailed: boolean;
30
- selectedId: string | null;
31
- recentIds: ReadonlySet<string>;
32
- /**
33
- * The §1.3 context bus.
34
- *
35
- * Takes `string | null` because the graph can *clear* the selection — a
36
- * click on empty stage — and no other column can. `workspace.ts`'s `select`
37
- * has always accepted `null`; this prop was merely narrower than the thing
38
- * behind it. The tree, the note column and the rail pass a `string`, which
39
- * a handler accepting the wider type takes without a cast.
40
- */
41
- onSelect: (id: string | null) => void;
42
- onRefresh: () => void;
43
- onOpen: (slug: string) => void;
44
- /** Save a note's body. Resolves `true` when the write landed. */
45
- onSave: (slug: string, body: string) => Promise<boolean>;
46
- /**
47
- * Ask before a mutation that would invalidate an open draft, closing it if
48
- * the user agrees. `false` means keep editing: do not proceed.
49
- */
50
- onMutate: () => boolean;
51
- /** Slot the note column fills with its editor handle, for the shell's guards. */
52
- editor: { current: { dirty(): boolean; discard(): void } | null };
53
- /** Epoch ms for relative times, read once per render by the shell. */
54
- now: number;
55
- /**
56
- * The graph column's three injected ports (§7.5, §10).
57
- *
58
- * Threaded through rather than imported by `Graph.tsx` for the reason
59
- * `api.dom.ts` exists: `createSigmaRenderer` imports sigma, which is a
60
- * `ReferenceError` outside a browser, so the module that names it must stay
61
- * reachable only from a `.tsx` entry point.
62
- */
63
- renderer: RendererFactory;
64
- storage: PositionStorage;
65
- host: SchemeHost;
66
- /**
67
- * The scheme the shell resolved from the user's theme choice
68
- * (`shell/theme.model.ts`'s `effectiveScheme`), or `null` to let the column
69
- * read the OS (`graph/scheme.ts`'s `schemeOf`). The stylesheet carries the
70
- * choice to everything CSS paints; WebGL cannot read custom properties, so
71
- * the graph needs the decision handed to it as a value.
72
- */
73
- scheme: ColorScheme | null;
74
- /** The boot graph fetch failed — see `state.ts`'s `graphFailed`. */
75
- bootFailed: boolean;
76
- /** Slot the graph column fills with its `fit`, for the global `g` key. */
77
- fit: { current: (() => void) | null };
78
- /** `?sliders=1` — the hidden force tuner (docs/weave-workspace.md §15.7). */
79
- tuner: boolean;
80
- }
81
-
82
- /** One column: a titled region and whichever surface fills it. */
83
- function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
84
- const copy = emptyStateFor(id);
85
- return (
86
- <section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
87
- <h2 class="weave-col-title">{copy.title}</h2>
88
- {id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} recentIds={props.recentIds} onSelect={props.onSelect} onMutate={props.onMutate} onRefresh={props.onRefresh} now={props.now} /> : null}
89
- {id === "note" ? (
90
- <Note
91
- note={props.note}
92
- loadFailed={props.noteFailed}
93
- graph={props.graph}
94
- selectedId={props.selectedId}
95
- onSelect={props.onSelect}
96
- now={props.now}
97
- onOpen={props.onOpen}
98
- onSave={props.onSave}
99
- editor={props.editor}
100
- />
101
- ) : null}
102
- {id === "graph" ? (
103
- <Graph
104
- graph={props.graph}
105
- selectedId={props.selectedId}
106
- onSelect={props.onSelect}
107
- renderer={props.renderer}
108
- storage={props.storage}
109
- host={props.host}
110
- scheme={props.scheme}
111
- bootFailed={props.bootFailed}
112
- fit={props.fit}
113
- tuner={props.tuner}
114
- />
115
- ) : null}
116
- {id === "graph" ? <ContextRail graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} /> : null}
117
- </section>
118
- );
119
- }
120
-
121
- export function Columns(props: ColumnsProps) {
122
- const grid = useRef<HTMLDivElement | null>(null);
123
- useLayoutEffect(() => {
124
- const element = grid.current;
125
- if (element === null) return;
126
- for (const { id, width } of props.resolved) element.style.setProperty(`--weave-col-${id}`, `${Math.round(width)}px`);
127
- }, [props.resolved]);
128
- return <div class="weave-grid" ref={grid} data-columns={props.resolved.length}>
129
- {props.resolved.map((column, index) => {
130
- const divider: DividerId | null = index < props.resolved.length - 1 && column.id !== "graph" ? column.id : null;
131
- return [
132
- <Column key={`${column.id}-column`} id={column.id} props={props} />,
133
- divider === null ? null : <Divider key={`${column.id}-divider`} label={`Resize ${column.id} column`}
134
- onDown={(x) => props.onDown(divider, x)} onMove={props.onMove} onUp={props.onUp}
135
- onKey={(key) => props.onKey(divider, key)} />,
136
- ];
137
- })}
138
- </div>;
139
- }
@@ -1,15 +0,0 @@
1
- export interface DividerProps {
2
- label: string;
3
- onDown: (clientX: number) => void;
4
- onMove: (clientX: number) => void;
5
- onUp: () => void;
6
- onKey: (key: string) => void;
7
- }
8
-
9
- export function Divider(props: DividerProps) {
10
- return <div class="weave-divider" role="separator" aria-orientation="vertical" aria-label={props.label} tabIndex={0}
11
- onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); props.onDown(event.clientX); }}
12
- onPointerMove={(event) => props.onMove(event.clientX)}
13
- onPointerUp={(event) => { event.currentTarget.releasePointerCapture(event.pointerId); props.onUp(); }}
14
- onKeyDown={(event) => props.onKey(event.key)} />;
15
- }