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
@@ -0,0 +1,92 @@
1
+ /** Theme candidates. Catppuccin colors: https://github.com/catppuccin/palette (MIT).
2
+ * Latte colors are deepened and Frappé red lifted for contrast on raised controls.
3
+ * `light` and `dark` keep existing saved choices compatible.
4
+ */
5
+ const latte = {
6
+ bg: "#eff1f5", panel: "#e6e9ef", page: "#dce0e8", raise: "#ccd0da",
7
+ fg: "#4c4f69", dim: "#56586a", faint: "#606274", line: "#ccd0da", "line-strong": "#bcc0cc",
8
+ accent: "#7113ec", ok: "#28641b", warn: "#7c4f10", bad: "#b20d30",
9
+ };
10
+ const macchiato = {
11
+ bg: "#24273a", panel: "#1e2030", page: "#181926", raise: "#363a4f",
12
+ fg: "#cad3f5", dim: "#a5adcb", faint: "#939ab7", line: "#363a4f", "line-strong": "#494d64",
13
+ accent: "#c6a0f6", ok: "#a6da95", warn: "#eed49f", bad: "#ed8796",
14
+ };
15
+
16
+ /** One table drives CSS, WebGL and the appearance theme choices. */
17
+ export const THEMES = {
18
+ light: { name: "Catppuccin Latte · Mauve", scheme: "light", colors: latte },
19
+ "latte-blue": { name: "Catppuccin Latte · Blue", scheme: "light", colors: { ...latte, accent: "#1750bf" } },
20
+ "frappe-teal": { name: "Catppuccin Frappé · Teal", scheme: "dark", colors: {
21
+ bg: "#303446", panel: "#292c3c", page: "#232634", raise: "#414559",
22
+ fg: "#c6d0f5", dim: "#b5bfe2", faint: "#a5adce", line: "#414559", "line-strong": "#51576d",
23
+ accent: "#81c8be", ok: "#a6d189", warn: "#e5c890", bad: "#f4a4a6",
24
+ } },
25
+ dark: { name: "Catppuccin Macchiato · Mauve", scheme: "dark", colors: macchiato },
26
+ "macchiato-peach": { name: "Catppuccin Macchiato · Peach", scheme: "dark", colors: { ...macchiato, accent: "#f5a97f" } },
27
+ "mocha-lavender": { name: "Catppuccin Mocha · Lavender", scheme: "dark", colors: {
28
+ bg: "#1e1e2e", panel: "#181825", page: "#11111b", raise: "#313244",
29
+ fg: "#cdd6f4", dim: "#a6adc8", faint: "#9399b2", line: "#313244", "line-strong": "#45475a",
30
+ accent: "#b4befe", ok: "#a6e3a1", warn: "#f9e2af", bad: "#f38ba8",
31
+ } },
32
+ "paper-blue": { name: "Paper · Blue", scheme: "light", colors: {
33
+ bg: "#f4f6f8", panel: "#e9edf2", page: "#ffffff", raise: "#dce3eb",
34
+ fg: "#182334", dim: "#42536a", faint: "#526176", line: "#dce3eb", "line-strong": "#bac6d4",
35
+ accent: "#174ea6", ok: "#21623b", warn: "#805300", bad: "#a3223c",
36
+ } },
37
+ "ink-blue": { name: "Ink · Blue", scheme: "dark", colors: {
38
+ bg: "#1b2430", panel: "#141c27", page: "#101720", raise: "#2b3748",
39
+ fg: "#edf2f8", dim: "#becadc", faint: "#a5b5cb", line: "#2b3748", "line-strong": "#46566d",
40
+ accent: "#8bbcff", ok: "#91d5a5", warn: "#edc47b", bad: "#ff9fae",
41
+ } },
42
+ "sand-amber": { name: "Sand · Amber", scheme: "light", colors: {
43
+ bg: "#f7f3ec", panel: "#ede6da", page: "#fffdf8", raise: "#e2d8c8",
44
+ fg: "#30291f", dim: "#5b4e3f", faint: "#675847", line: "#e2d8c8", "line-strong": "#c7b8a2",
45
+ accent: "#814500", ok: "#365c2a", warn: "#795000", bad: "#a12c32",
46
+ } },
47
+ "ember-amber": { name: "Ember · Amber", scheme: "dark", colors: {
48
+ bg: "#29231f", panel: "#211c19", page: "#191512", raise: "#3c322b",
49
+ fg: "#f3e9dc", dim: "#d0bfab", faint: "#bfa991", line: "#3c322b", "line-strong": "#5c4d40",
50
+ accent: "#edb96b", ok: "#b5cf8a", warn: "#efc57a", bad: "#f69a91",
51
+ } },
52
+ "mist-teal": { name: "Mist · Teal", scheme: "light", colors: {
53
+ bg: "#eff5f3", panel: "#e1ece8", page: "#fafffd", raise: "#d2e2dc",
54
+ fg: "#18342e", dim: "#3c5b52", faint: "#48655b", line: "#d2e2dc", "line-strong": "#acc5bc",
55
+ accent: "#006457", ok: "#2a5e35", warn: "#795300", bad: "#a52a43",
56
+ } },
57
+ "forest-teal": { name: "Forest · Teal", scheme: "dark", colors: {
58
+ bg: "#1c2b27", panel: "#16221e", page: "#101b17", raise: "#2b4038",
59
+ fg: "#e6f2ec", dim: "#b8d0c4", faint: "#a2bfb0", line: "#2b4038", "line-strong": "#456255",
60
+ accent: "#7cd8bb", ok: "#a5d6a0", warn: "#e6c582", bad: "#f5a2ac",
61
+ } },
62
+ } as const;
63
+
64
+ export type PaletteChoice = keyof typeof THEMES;
65
+ export type ThemeChoice = "system" | PaletteChoice;
66
+ export const THEME_CHOICES: readonly ThemeChoice[] = ["system", ...Object.keys(THEMES) as PaletteChoice[]];
67
+
68
+ export function isThemeChoice(value: unknown): value is ThemeChoice {
69
+ return typeof value === "string" && THEME_CHOICES.includes(value as ThemeChoice);
70
+ }
71
+
72
+ /** Accent selection is independent of the theme's surface palette. */
73
+ export const ACCENTS = {
74
+ mauve: { name: "Mauve", light: "#7113ec", dark: "#cba9f8" },
75
+ blue: { name: "Blue", light: "#1750bf", dark: "#8bbcff" },
76
+ teal: { name: "Teal", light: "#006457", dark: "#7cd8bb" },
77
+ peach: { name: "Peach", light: "#814500", dark: "#f5a97f" },
78
+ rose: { name: "Rose", light: "#a52a43", dark: "#f4a4a6" },
79
+ green: { name: "Green", light: "#28641b", dark: "#a6da95" },
80
+ amber: { name: "Amber", light: "#775000", dark: "#edb96b" },
81
+ lavender: { name: "Lavender", light: "#445198", dark: "#b4befe" },
82
+ } as const;
83
+ export type AccentChoice = "theme" | keyof typeof ACCENTS;
84
+ export type GraphTheme = PaletteChoice | { theme: PaletteChoice; accent: AccentChoice };
85
+
86
+ export function themeId(theme: GraphTheme): PaletteChoice {
87
+ return typeof theme === "string" ? theme : theme.theme;
88
+ }
89
+
90
+ export function accentColor(theme: PaletteChoice, accent: AccentChoice): string {
91
+ return accent === "theme" ? THEMES[theme].colors.accent : ACCENTS[accent][THEMES[theme].scheme];
92
+ }
@@ -0,0 +1,310 @@
1
+ /** Serializable tab and pane state shared by the browser and persistence API. */
2
+ import { isThemeChoice, THEMES } from "./themes";
3
+ import type { ThemeChoice } from "./themes";
4
+ import { DEFAULT_PREFERENCES, parsePreferences } from "./preferences";
5
+ import type { Preferences } from "./preferences";
6
+
7
+ export interface WorkspaceTab {
8
+ readonly id: string;
9
+ readonly kind: "document" | "graph";
10
+ readonly history: readonly (string | null)[];
11
+ readonly cursor: number;
12
+ readonly scroll: number;
13
+ }
14
+
15
+ export interface Pane {
16
+ readonly id: string;
17
+ readonly tabs: readonly WorkspaceTab[];
18
+ readonly activeTab: string;
19
+ readonly lastDocumentTab?: string;
20
+ }
21
+
22
+ export interface WorkspaceLayout {
23
+ readonly version: 1;
24
+ readonly panes: readonly Pane[];
25
+ readonly activePane: string;
26
+ readonly split: "right" | "down";
27
+ readonly ratio: number;
28
+ readonly treeVisible: boolean;
29
+ readonly contextVisible: boolean;
30
+ readonly treeWidth: number;
31
+ readonly contextWidth: number;
32
+ readonly theme: ThemeChoice;
33
+ readonly preferences: Preferences;
34
+ }
35
+
36
+ const MAX_TABS = 40;
37
+ const MAX_HISTORY = 100;
38
+ const MAX_SCROLL = 1_000_000_000;
39
+
40
+ const emptyDocument = (id: string): WorkspaceTab => ({ id, kind: "document", history: [null], cursor: 0, scroll: 0 });
41
+
42
+ export function initialLayout(): WorkspaceLayout {
43
+ return {
44
+ version: 1,
45
+ panes: [{ id: "pane-1", tabs: [emptyDocument("tab-1")], activeTab: "tab-1" }],
46
+ activePane: "pane-1",
47
+ split: "right",
48
+ ratio: 0.5,
49
+ treeVisible: true,
50
+ contextVisible: true,
51
+ treeWidth: 240,
52
+ contextWidth: 240,
53
+ theme: "system",
54
+ preferences: DEFAULT_PREFERENCES,
55
+ };
56
+ }
57
+
58
+ export function activePane(layout: WorkspaceLayout): Pane {
59
+ return layout.panes.find((pane) => pane.id === layout.activePane)!;
60
+ }
61
+
62
+ export function activeTab(layout: WorkspaceLayout): WorkspaceTab {
63
+ const pane = activePane(layout);
64
+ return pane.tabs.find((tab) => tab.id === pane.activeTab)!;
65
+ }
66
+
67
+ export function tabSelection(tab: WorkspaceTab): string | null {
68
+ return tab.kind === "document" ? tab.history[tab.cursor] ?? null : null;
69
+ }
70
+
71
+ function nextId(layout: WorkspaceLayout, prefix: "pane" | "tab"): string {
72
+ const ids = new Set(layout.panes.flatMap((pane) => [pane.id, ...pane.tabs.map((tab) => tab.id)]));
73
+ let n = 1;
74
+ while (ids.has(`${prefix}-${n}`)) n++;
75
+ return `${prefix}-${n}`;
76
+ }
77
+
78
+ function setActive(layout: WorkspaceLayout, pane: Pane, tab: WorkspaceTab): WorkspaceLayout {
79
+ return {
80
+ ...layout,
81
+ activePane: pane.id,
82
+ panes: layout.panes.map((item) => item.id === pane.id
83
+ ? rememberDocument({ ...pane, activeTab: tab.id, tabs: pane.tabs.map((current) => current.id === tab.id ? tab : current) })
84
+ : item),
85
+ };
86
+ }
87
+
88
+ function documentTab(pane: Pane): WorkspaceTab | undefined {
89
+ return pane.tabs.find((tab) => tab.id === pane.lastDocumentTab && tab.kind === "document")
90
+ ?? [...pane.tabs].reverse().find((tab) => tab.kind === "document");
91
+ }
92
+
93
+ function rememberDocument(pane: Pane): Pane {
94
+ const { lastDocumentTab: _previous, ...rest } = pane;
95
+ const tab = pane.tabs.find((tab) => tab.id === pane.activeTab && tab.kind === "document") ?? documentTab(pane);
96
+ return tab ? { ...rest, lastDocumentTab: tab.id } : rest;
97
+ }
98
+
99
+ /** Focus reading/editing without navigating away from the active document. */
100
+ export function focusDocument(layout: WorkspaceLayout): WorkspaceLayout {
101
+ if (activeTab(layout).kind === "document") return layout;
102
+ const pane = activePane(layout);
103
+ const tab = documentTab(pane);
104
+ return tab ? setActive(layout, pane, tab) : openDocument(layout, null, { newTab: true });
105
+ }
106
+
107
+ function findTab(layout: WorkspaceLayout, paneId: string, tabId: string): { pane: Pane; tab: WorkspaceTab } | null {
108
+ const pane = layout.panes.find((item) => item.id === paneId);
109
+ const tab = pane?.tabs.find((item) => item.id === tabId);
110
+ return pane && tab ? { pane, tab } : null;
111
+ }
112
+
113
+ function appendTab(layout: WorkspaceLayout, pane: Pane, tab: WorkspaceTab): WorkspaceLayout {
114
+ if (layout.panes.reduce((count, item) => count + item.tabs.length, 0) >= MAX_TABS) return layout;
115
+ return setActive(layout, { ...pane, tabs: [...pane.tabs, tab] }, tab);
116
+ }
117
+
118
+ function navigate(tab: WorkspaceTab, id: string | null): WorkspaceTab {
119
+ const history = tab.history.slice(0, tab.cursor + 1);
120
+ if (history.at(-1) === id) return tab;
121
+ history.push(id);
122
+ if (history.length > MAX_HISTORY) history.shift();
123
+ return { ...tab, history, cursor: history.length - 1, scroll: 0 };
124
+ }
125
+
126
+ export function openDocument(layout: WorkspaceLayout, id: string | null, options: { newTab?: boolean; paneId?: string } = {}): WorkspaceLayout {
127
+ let pane = layout.panes.find((item) => item.id === (options.paneId ?? layout.activePane));
128
+ if (!pane) return layout;
129
+ const activeTabId = pane.activeTab;
130
+ let tab = pane.tabs.find((item) => item.id === activeTabId);
131
+ if (tab?.kind === "graph") {
132
+ tab = documentTab(pane);
133
+ if (tab) pane = { ...pane, activeTab: tab.id };
134
+ else if (!options.newTab) options = { ...options, newTab: true };
135
+ }
136
+ if (options.newTab) return appendTab(layout, pane, navigate(emptyDocument(nextId(layout, "tab")), id));
137
+ if (!tab) return appendTab(layout, pane, navigate(emptyDocument(nextId(layout, "tab")), id));
138
+ const next = navigate(tab, id);
139
+ if (next === tab && pane.id === layout.activePane && activeTabId === tab.id) return layout;
140
+ return setActive(layout, pane, next);
141
+ }
142
+
143
+ export function openGraph(layout: WorkspaceLayout): WorkspaceLayout {
144
+ const located = layout.panes.flatMap((pane) => pane.tabs.filter((tab) => tab.kind === "graph").map((tab) => ({ pane, tab })))[0];
145
+ if (located) return located.pane.id === layout.activePane && located.pane.activeTab === located.tab.id
146
+ ? layout
147
+ : setActive(layout, located.pane, located.tab);
148
+ const pane = activePane(layout);
149
+ return appendTab(layout, pane, { id: nextId(layout, "tab"), kind: "graph", history: [null], cursor: 0, scroll: 0 });
150
+ }
151
+
152
+ export function activateTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
153
+ const found = findTab(layout, paneId, tabId);
154
+ return !found || (layout.activePane === paneId && found.pane.activeTab === tabId)
155
+ ? layout
156
+ : setActive(layout, found.pane, found.tab);
157
+ }
158
+
159
+ export function closeTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
160
+ const found = findTab(layout, paneId, tabId);
161
+ if (!found) return layout;
162
+ const { pane } = found;
163
+ if (pane.tabs.length === 1) {
164
+ const kept = layout.panes.find((item) => item.id !== paneId);
165
+ if (kept) return { ...layout, panes: [kept], activePane: kept.id };
166
+ const tab = emptyDocument(nextId(layout, "tab"));
167
+ return setActive(layout, { ...pane, tabs: [tab] }, tab);
168
+ }
169
+ const tabs = pane.tabs.filter((tab) => tab.id !== tabId);
170
+ const wasActive = pane.activeTab === tabId;
171
+ const neighbor = tabs[Math.max(0, pane.tabs.findIndex((tab) => tab.id === tabId) - 1)]!;
172
+ const updated = rememberDocument({ ...pane, tabs, activeTab: wasActive ? neighbor.id : pane.activeTab });
173
+ return { ...layout, activePane: wasActive ? paneId : layout.activePane, panes: layout.panes.map((item) => item.id === paneId ? updated : item) };
174
+ }
175
+
176
+ export function navigateTab(layout: WorkspaceLayout, paneId: string, delta: -1 | 1): WorkspaceLayout {
177
+ const pane = layout.panes.find((item) => item.id === paneId);
178
+ const tab = pane?.tabs.find((item) => item.id === pane.activeTab);
179
+ if (!pane || !tab || tab.kind !== "document") return layout;
180
+ const cursor = tab.cursor + delta;
181
+ if (cursor < 0 || cursor >= tab.history.length) return layout;
182
+ return setActive(layout, pane, { ...tab, cursor, scroll: 0 });
183
+ }
184
+
185
+ export function splitPane(layout: WorkspaceLayout, direction: "right" | "down"): WorkspaceLayout {
186
+ if (layout.panes.length === 2) return layout.split === direction ? layout : { ...layout, split: direction };
187
+ if (layout.panes.reduce((count, pane) => count + pane.tabs.length, 0) >= MAX_TABS) return layout;
188
+ const current = activeTab(layout);
189
+ const id = nextId(layout, "pane");
190
+ let tab = emptyDocument(nextId(layout, "tab"));
191
+ if (current.kind === "document") tab = { ...current, id: tab.id, history: [...current.history], scroll: current.scroll };
192
+ return { ...layout, panes: [...layout.panes, { id, tabs: [tab], activeTab: tab.id }], activePane: id, split: direction };
193
+ }
194
+
195
+ export function closePane(layout: WorkspaceLayout, paneId: string): WorkspaceLayout {
196
+ if (layout.panes.length !== 2 || !layout.panes.some((pane) => pane.id === paneId)) return layout;
197
+ const closed = layout.panes.find((pane) => pane.id === paneId)!;
198
+ const kept = layout.panes.find((pane) => pane.id !== paneId)!;
199
+ return { ...layout, panes: [{ ...kept, tabs: [...kept.tabs, ...closed.tabs] }], activePane: kept.id };
200
+ }
201
+
202
+ /** Move a tab before another tab, or to the end, without changing its identity or selection. */
203
+ export function reorderTab(layout: WorkspaceLayout, paneId: string, tabId: string, before: string | null): WorkspaceLayout {
204
+ const found = findTab(layout, paneId, tabId);
205
+ if (!found || before === tabId || before !== null && !found.pane.tabs.some((tab) => tab.id === before)) return layout;
206
+ const tabs = found.pane.tabs.filter((tab) => tab.id !== tabId);
207
+ tabs.splice(before === null ? tabs.length : tabs.findIndex((tab) => tab.id === before), 0, found.tab);
208
+ if (tabs.every((tab, index) => tab === found.pane.tabs[index])) return layout;
209
+ return { ...layout, panes: layout.panes.map((pane) => pane.id === paneId ? { ...pane, tabs } : pane) };
210
+ }
211
+
212
+ /** Accept a tab drop only in another existing pane. */
213
+ export function tabDropSource(layout: WorkspaceLayout, targetPane: string, tabId: string | null): string | null {
214
+ if (!layout.panes.some((pane) => pane.id === targetPane)) return null;
215
+ return layout.panes.find((pane) => pane.id !== targetPane && pane.tabs.some((tab) => tab.id === tabId))?.id ?? null;
216
+ }
217
+
218
+ export function moveTab(layout: WorkspaceLayout, paneId: string, tabId: string): WorkspaceLayout {
219
+ const found = findTab(layout, paneId, tabId);
220
+ if (!found) return layout;
221
+ const target = layout.panes.find((pane) => pane.id !== paneId) ?? { id: nextId(layout, "pane"), tabs: [], activeTab: "" };
222
+ const sourceTabs = found.pane.tabs.filter((tab) => tab.id !== tabId);
223
+ const destination = rememberDocument({ ...target, tabs: [...target.tabs, found.tab], activeTab: found.tab.id });
224
+ if (!sourceTabs.length && layout.panes.length === 2) return { ...layout, panes: [destination], activePane: target.id };
225
+ if (!sourceTabs.length && layout.panes.reduce((count, pane) => count + pane.tabs.length, 0) >= MAX_TABS) return layout;
226
+ const placeholder = sourceTabs.length ? null : emptyDocument(nextId(layout, "tab"));
227
+ const remaining = sourceTabs.length ? sourceTabs : [placeholder!];
228
+ 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 });
229
+ return { ...layout, activePane: target.id, panes: layout.panes.length === 1
230
+ ? [source, destination]
231
+ : layout.panes.map((pane) => pane.id === source.id ? source : destination) };
232
+ }
233
+
234
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
235
+ return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
236
+ }
237
+
238
+ function record(value: unknown): value is Record<string, unknown> {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
241
+
242
+ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
243
+ return typeof value === "string" && value.length <= 2048 && (prefix === undefined
244
+ ? value.length > 0 && !/[\u0000-\u001f\u007f]/.test(value)
245
+ : new RegExp(`^${prefix}-[1-9][0-9]{0,8}$`).test(value));
246
+ }
247
+
248
+ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
249
+ if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...(Object.hasOwn(value, "preferences") ? ["preferences"] : [])])) return null;
250
+ if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
251
+ if (value["split"] !== "right" && value["split"] !== "down") return null;
252
+ if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
253
+ if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
254
+ if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
255
+ if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
256
+ if (!isThemeChoice(value["theme"])) return null;
257
+ let preferences = Object.hasOwn(value, "preferences") ? parsePreferences(value["preferences"]) : DEFAULT_PREFERENCES;
258
+ if (preferences === null) return null;
259
+ const previousPreferences = value["preferences"];
260
+ if (value["theme"] !== "system" && (!record(previousPreferences) || !Object.hasOwn(previousPreferences, "lightTheme"))) {
261
+ preferences = { ...preferences, [`${THEMES[value["theme"]].scheme}Theme`]: value["theme"] };
262
+ }
263
+ const ids = new Set<string>();
264
+ let count = 0;
265
+ let graphs = 0;
266
+ const panes: Pane[] = [];
267
+ for (const rawPane of value["panes"]) {
268
+ 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;
269
+ ids.add(rawPane["id"]);
270
+ const tabs: WorkspaceTab[] = [];
271
+ let activeFound = false;
272
+ for (const rawTab of rawPane["tabs"]) {
273
+ if (!record(rawTab) || !exactKeys(rawTab, ["id", "kind", "history", "cursor", "scroll"]) || !boundedId(rawTab["id"], "tab") || ids.has(rawTab["id"])) return null;
274
+ ids.add(rawTab["id"]);
275
+ if (rawTab["kind"] !== "document" && rawTab["kind"] !== "graph") return null;
276
+ const kind = rawTab["kind"] as WorkspaceTab["kind"];
277
+ 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;
278
+ if (rawTab["history"].some((id) => id !== null && !boundedId(id))) return null;
279
+ if (typeof rawTab["scroll"] !== "number" || !Number.isFinite(rawTab["scroll"]) || rawTab["scroll"] < 0 || rawTab["scroll"] > MAX_SCROLL) return null;
280
+ if (kind === "graph") graphs++;
281
+ count++;
282
+ if (rawTab["id"] === rawPane["activeTab"]) activeFound = true;
283
+ tabs.push({ id: rawTab["id"], kind, history: rawTab["history"] as (string | null)[], cursor: rawTab["cursor"] as number, scroll: rawTab["scroll"] });
284
+ }
285
+ if (!activeFound) return null;
286
+ const lastDocumentTab = rawPane["lastDocumentTab"];
287
+ if (Object.hasOwn(rawPane, "lastDocumentTab") && (!boundedId(lastDocumentTab, "tab") || !tabs.some((tab) => tab.id === lastDocumentTab && tab.kind === "document"))) return null;
288
+ panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
289
+ }
290
+ if (count > MAX_TABS || graphs > 1 || !panes.some((pane) => pane.id === value["activePane"])) return null;
291
+ 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"], preferences };
292
+ }
293
+
294
+ /** Startup choice resets navigation only; appearance and sidebar preferences survive. */
295
+ export function restoreWorkspace(layout: WorkspaceLayout): WorkspaceLayout {
296
+ if (layout.preferences.startup === "restore") return layout;
297
+ const fresh = initialLayout();
298
+ return { ...layout, panes: fresh.panes, activePane: fresh.activePane };
299
+ }
300
+
301
+ /** Opening in the background preserves the active pane and tab, including from Graph. */
302
+ export function openWithPreferences(layout: WorkspaceLayout, id: string | null, options: { newTab?: boolean; paneId?: string } = {}): WorkspaceLayout {
303
+ const next = openDocument(layout, id, options);
304
+ if (!options.newTab || layout.preferences.focusNewTabs) return next;
305
+ return { ...next, activePane: layout.activePane, panes: next.panes.map((pane) => {
306
+ const previous = layout.panes.find((item) => item.id === pane.id)!;
307
+ const { lastDocumentTab: _remembered, ...rest } = pane;
308
+ return { ...rest, activeTab: previous.activeTab, ...(previous.lastDocumentTab ? { lastDocumentTab: previous.lastDocumentTab } : {}) };
309
+ }) };
310
+ }
@@ -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
- }