pi-weave 0.3.0 → 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 (40) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -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 +210 -178
  9. package/src/web/client/graph/Graph.tsx +18 -0
  10. package/src/web/client/graph/column.model.ts +19 -2
  11. package/src/web/client/graph/graph.model.ts +1 -1
  12. package/src/web/client/graph/positions.ts +1 -1
  13. package/src/web/client/graph/renderer.ts +9 -1
  14. package/src/web/client/note/Note.tsx +66 -91
  15. package/src/web/client/note/drafts.ts +172 -0
  16. package/src/web/client/note/note.model.ts +2 -34
  17. package/src/web/client/search/SearchPalette.tsx +9 -8
  18. package/src/web/client/shell/ContextRail.tsx +8 -9
  19. package/src/web/client/shell/Shell.tsx +371 -254
  20. package/src/web/client/shell/StatusBar.tsx +1 -1
  21. package/src/web/client/shell/focus.model.ts +2 -2
  22. package/src/web/client/shell/keys.model.ts +26 -31
  23. package/src/web/client/shell/keys.ts +1 -0
  24. package/src/web/client/shell/shell.model.ts +31 -107
  25. package/src/web/client/shell/theme.model.ts +4 -4
  26. package/src/web/client/shell/theme.ts +12 -93
  27. package/src/web/client/shell/workspace.css.ts +117 -0
  28. package/src/web/client/tree/Tree.tsx +43 -14
  29. package/src/web/client/tree/tree.model.ts +19 -0
  30. package/src/web/client/workspace.ts +19 -1
  31. package/src/web/server/routes.ts +34 -3
  32. package/src/web/server/server.ts +5 -0
  33. package/src/web/server/workspace-state.ts +121 -0
  34. package/src/web/shared/workspace.ts +270 -0
  35. package/src/web/client/selection.storage.ts +0 -69
  36. package/src/web/client/shell/Columns.tsx +0 -139
  37. package/src/web/client/shell/Divider.tsx +0 -15
  38. package/src/web/client/shell/Header.tsx +0 -119
  39. package/src/web/client/shell/drag.model.ts +0 -35
  40. package/src/web/client/shell/layout.model.ts +0 -132
@@ -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
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * The header bar (weave-workspace §1.2).
3
- *
4
- * Title, the `⌘K` search button, the `vault:N · repo:… · N nodes` summary,
5
- * the refresh button. Every string comes from `shell.model.ts`.
6
- *
7
- * The search control was `disabled` through P1–P3 because search was P4 and
8
- * an affordance that looks live but is not would be the dishonest option. P4
9
- * landed, so it is live — and it is a `<button>`, not an `<input>`: the
10
- * palette owns the only text field in the workspace, and a header box you
11
- * could type into whose contents were then thrown away when the overlay
12
- * opened would be a worse lie than the disabled version it replaced.
13
- */
14
-
15
- import { useState } from "preact/hooks";
16
- import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
17
- import type { ThemeButtonView } from "./theme.model";
18
- import type { HeaderSummary } from "./shell.model";
19
- import { REFRESH_ICON_PATHS, SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
20
-
21
- export interface HeaderProps {
22
- summary: HeaderSummary;
23
- shortcut: string;
24
- onRefresh: () => void;
25
- /** Opens the ⌘K palette. The same action the global key performs. */
26
- onSearch: () => void;
27
- /** The theme control's face (glyph + hint), from `shell.model.ts`'s friend in `theme.model.ts`. */
28
- theme: ThemeButtonView;
29
- /** Advance the theme choice one step in its cycle. The same action the `t` key performs. */
30
- onTheme: () => void;
31
- }
32
-
33
- /**
34
- * The refresh control, drawn rather than typed.
35
- *
36
- * The `⟳` it replaces was a text character, so its weight and shape were
37
- * whatever the platform's font felt like — the one glyph in the header that
38
- * the theme did not own. These two strokes (`REFRESH_ICON_PATHS`, in
39
- * `shell.model.ts`) are stroked in `currentColor`, so hover recolours them
40
- * like any other text glyph.
41
- *
42
- * The spin is one 600 ms turn on click, restarted only by the next click —
43
- * it says "the request left", which is the truth, and stops rather than
44
- * pretending to know when the refetch lands (the refetch is fire-and-forget
45
- * by §7's register, and a spinner that waits for a signal a 304 may never
46
- * fire would hang forever). Reduced-motion users get the static glyph back,
47
- * as everywhere else in the sheet.
48
- */
49
- function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
50
- const [spinning, setSpinning] = useState(false);
51
- return (
52
- <button
53
- type="button"
54
- class="weave-refresh"
55
- onClick={() => {
56
- onRefresh();
57
- setSpinning(true);
58
- }}
59
- onAnimationEnd={() => setSpinning(false)}
60
- title="Refetch everything"
61
- aria-busy={spinning ? "true" : undefined}
62
- >
63
- <svg
64
- class={spinning ? "weave-refresh-glyph weave-refresh-spinning" : "weave-refresh-glyph"}
65
- viewBox="0 0 24 24"
66
- width={14}
67
- height={14}
68
- fill="none"
69
- stroke="currentColor"
70
- stroke-width={2}
71
- stroke-linecap="round"
72
- stroke-linejoin="round"
73
- aria-hidden="true"
74
- >
75
- {REFRESH_ICON_PATHS.map((d) => (
76
- <path key={d} d={d} />
77
- ))}
78
- </svg>
79
- </button>
80
- );
81
- }
82
-
83
- export function Header(props: HeaderProps) {
84
- return (
85
- <header class="weave-header">
86
- <span class="weave-brand">
87
- <img
88
- class="weave-brand-mark"
89
- src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
90
- alt=""
91
- width={18}
92
- height={18}
93
- />
94
- pi-weave
95
- </span>
96
- <button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
97
- <span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
98
- <kbd>{props.shortcut}</kbd>
99
- </button>
100
- <span class="weave-summary">
101
- {summaryParts(props.summary).map((part) => (
102
- <span key={part} class="weave-summary-part">
103
- {part}
104
- </span>
105
- ))}
106
- </span>
107
- <button
108
- type="button"
109
- class="weave-theme"
110
- onClick={props.onTheme}
111
- title={props.theme.hint}
112
- aria-label={props.theme.hint}
113
- >
114
- {props.theme.glyph}
115
- </button>
116
- <RefreshButton onRefresh={props.onRefresh} />
117
- </header>
118
- );
119
- }
@@ -1,35 +0,0 @@
1
- import { COLUMNS, resizeAt } from "./layout.model";
2
- import type { DividerId, LayoutState } from "./layout.model";
3
-
4
- export interface DragState { readonly divider: DividerId; readonly origin: number; readonly base: LayoutState }
5
- export function beginDrag(divider: DividerId, origin: number, base: LayoutState): DragState { return { divider, origin, base }; }
6
- export function dragTo(drag: DragState, clientX: number, available: number): LayoutState { return resizeAt(drag.base, drag.divider, clientX - drag.origin, available); }
7
- export function dragChanged(drag: DragState, final: LayoutState): boolean {
8
- return COLUMNS.some((id) => drag.base.fractions[id] !== final.fractions[id]);
9
- }
10
- export const NUDGE_PX = 24;
11
- export function nudgeFor(key: string): number { return key === "ArrowLeft" ? -NUDGE_PX : key === "ArrowRight" ? NUDGE_PX : 0; }
12
-
13
- export function dividerHandlers(host: {
14
- layout(): LayoutState;
15
- width(): number;
16
- setLayout(next: LayoutState): void;
17
- persist(layout: LayoutState): void;
18
- }) {
19
- let active: DragState | null = null;
20
- return {
21
- onDown(divider: DividerId, clientX: number) { active = beginDrag(divider, clientX, host.layout()); },
22
- onMove(clientX: number) { if (active !== null) host.setLayout(dragTo(active, clientX, host.width())); },
23
- onUp() {
24
- const drag = active;
25
- active = null;
26
- if (drag !== null && dragChanged(drag, host.layout())) host.persist(host.layout());
27
- },
28
- onKey(divider: DividerId, key: string) {
29
- const next = resizeAt(host.layout(), divider, nudgeFor(key), host.width());
30
- if (next === host.layout()) return;
31
- host.setLayout(next);
32
- host.persist(next);
33
- },
34
- };
35
- }