pi-weave 0.3.1 → 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.
- package/README.md +14 -1
- package/package.json +2 -1
- package/src/web/client/dist/app.js +88 -124
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +11 -74
- package/src/web/client/graph/column.model.ts +3 -13
- package/src/web/client/graph/graph.model.ts +30 -62
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +2 -2
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +5 -4
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +11 -1
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +61 -24
- package/src/web/client/shell/StatusBar.tsx +1 -5
- package/src/web/client/shell/keys.model.ts +14 -8
- package/src/web/client/shell/shell.model.ts +1 -21
- package/src/web/client/shell/theme.model.ts +26 -96
- package/src/web/client/shell/theme.ts +20 -145
- package/src/web/client/shell/workspace.css.ts +27 -4
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +12 -1
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +44 -4
package/src/web/server/routes.ts
CHANGED
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { createHash, randomUUID } from "node:crypto";
|
|
47
|
+
import { createReadStream } from "node:fs";
|
|
48
|
+
import { pipeline } from "node:stream/promises";
|
|
49
|
+
import { createVaultBackup } from "./backup";
|
|
47
50
|
import { readFile } from "node:fs/promises";
|
|
48
51
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
49
52
|
import type { WorkspaceSnapshot } from "../../core/cache/workspace";
|
|
@@ -379,8 +382,16 @@ async function route(
|
|
|
379
382
|
if (method === "GET" && path === "/") return sendShell(deps, res);
|
|
380
383
|
if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
|
|
381
384
|
if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
|
|
385
|
+
if (method === "GET" && path === "/api/backup") {
|
|
386
|
+
const backup = await createVaultBackup(deps.vaultRoot);
|
|
387
|
+
try {
|
|
388
|
+
res.writeHead(200, { ...baseHeaders(), "content-type": "application/zip", "content-disposition": `attachment; filename="weave-vault-${new Date().toISOString().slice(0, 10)}.zip"`, "cache-control": "no-store" });
|
|
389
|
+
await pipeline(createReadStream(backup.path), res);
|
|
390
|
+
} finally { await backup.dispose(); }
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
382
393
|
if (method === "GET" && path === "/api/workspace-state") {
|
|
383
|
-
return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest() }, { "cache-control": "no-store" });
|
|
394
|
+
return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest(), info: { vaultRoot: deps.vaultRoot, version: (JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")) as { version: string }).version } }, { "cache-control": "no-store" });
|
|
384
395
|
}
|
|
385
396
|
if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
|
|
386
397
|
if (path.startsWith("/api/folder/")) {
|
package/src/web/shared/layout.ts
CHANGED
|
@@ -41,14 +41,13 @@ const ALPHA_MIN = 0.001;
|
|
|
41
41
|
* Mutable because picking these numbers is an act of taste, not of
|
|
42
42
|
* derivation: the difference between "one hairball" and "legible groups" is a
|
|
43
43
|
* ratio between containment cohesion, charge and centre gravity that is far
|
|
44
|
-
* easier to *see* than to reason about. The
|
|
45
|
-
* `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7)
|
|
46
|
-
* and re-
|
|
44
|
+
* easier to *see* than to reason about. The settings sliders (see
|
|
45
|
+
* `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) update this record
|
|
46
|
+
* and re-run the layout live, so a human can find the values by eye and they then
|
|
47
47
|
* get frozen back into {@link FORCE_DEFAULTS}.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* the one writer, and it is unreachable without the query flag.
|
|
49
|
+
* `computeLayout(model, { seed })` stays deterministic for a given set of forces.
|
|
50
|
+
* Settings are the production writer of this record.
|
|
52
51
|
*
|
|
53
52
|
* ponytail: module-level mutable state, fine at one graph per page — thread it
|
|
54
53
|
* as a `LayoutOptions` field if a second concurrent consumer ever appears.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Validated preferences, stored alongside the workspace's existing snapshots. */
|
|
2
|
+
import { FORCE_DEFAULTS } from "./layout";
|
|
3
|
+
import type { ForceConstants } from "./layout";
|
|
4
|
+
import { ACCENTS, THEMES, isThemeChoice } from "./themes";
|
|
5
|
+
import type { AccentChoice, PaletteChoice } from "./themes";
|
|
6
|
+
|
|
7
|
+
/** One slider: which constant it drives, and the range it may drive it over. */
|
|
8
|
+
export interface SliderSpec {
|
|
9
|
+
readonly key: keyof ForceConstants;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
/** One line under the label saying what moving it does. */
|
|
12
|
+
readonly hint: string;
|
|
13
|
+
readonly min: number;
|
|
14
|
+
readonly max: number;
|
|
15
|
+
readonly step: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Graph forces, ordered from group cohesion to repulsion and centering. */
|
|
19
|
+
export const FORCE_SLIDERS: readonly SliderSpec[] = [
|
|
20
|
+
{ key: "containsStrength", label: "Group cohesion", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
|
|
21
|
+
{ key: "containsRest", label: "Group radius", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
|
|
22
|
+
{ key: "relationStrength", label: "Link strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
|
|
23
|
+
{ key: "relationDistance", label: "Link distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
|
|
24
|
+
{ key: "charge", label: "Repulsion", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
|
|
25
|
+
{ key: "chargeMax", label: "Repulsion range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
|
|
26
|
+
{ key: "center", label: "Center gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export interface Preferences {
|
|
31
|
+
readonly lightTheme: PaletteChoice;
|
|
32
|
+
readonly darkTheme: PaletteChoice;
|
|
33
|
+
readonly accent: AccentChoice;
|
|
34
|
+
readonly fontSize: number;
|
|
35
|
+
readonly readable: boolean;
|
|
36
|
+
readonly spellcheck: boolean;
|
|
37
|
+
readonly defaultEdit: boolean;
|
|
38
|
+
readonly startup: "restore" | "empty";
|
|
39
|
+
readonly focusNewTabs: boolean;
|
|
40
|
+
readonly groupColors: boolean;
|
|
41
|
+
readonly forces: ForceConstants;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_PREFERENCES: Preferences = {
|
|
45
|
+
lightTheme: "light", darkTheme: "dark", accent: "theme", fontSize: 14, readable: true, spellcheck: true, defaultEdit: false,
|
|
46
|
+
startup: "restore", focusNewTabs: true, groupColors: true, forces: { ...FORCE_DEFAULTS },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parsePreferences(input: unknown): Preferences | null {
|
|
54
|
+
if (!record(input)) return null;
|
|
55
|
+
const legacy = !Object.hasOwn(input, "lightTheme") && !Object.hasOwn(input, "darkTheme");
|
|
56
|
+
const value = legacy ? { ...input, lightTheme: "light", darkTheme: "dark" } : input;
|
|
57
|
+
if (Object.keys(value).length !== Object.keys(DEFAULT_PREFERENCES).length) return null;
|
|
58
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
59
|
+
const theme = value[`${scheme}Theme`];
|
|
60
|
+
if (!isThemeChoice(theme) || theme === "system" || THEMES[theme].scheme !== scheme) return null;
|
|
61
|
+
}
|
|
62
|
+
if (value["accent"] !== "theme" && (typeof value["accent"] !== "string" || !Object.hasOwn(ACCENTS, value["accent"]))) return null;
|
|
63
|
+
if (typeof value["fontSize"] !== "number" || !Number.isInteger(value["fontSize"]) || value["fontSize"] < 12 || value["fontSize"] > 22) return null;
|
|
64
|
+
for (const key of ["readable", "spellcheck", "defaultEdit", "focusNewTabs", "groupColors"] as const) {
|
|
65
|
+
if (typeof value[key] !== "boolean") return null;
|
|
66
|
+
}
|
|
67
|
+
if (value["startup"] !== "restore" && value["startup"] !== "empty") return null;
|
|
68
|
+
const forces = value["forces"];
|
|
69
|
+
if (!record(forces) || Object.keys(forces).length !== FORCE_SLIDERS.length) return null;
|
|
70
|
+
for (const { key, min, max } of FORCE_SLIDERS) {
|
|
71
|
+
const force = forces[key];
|
|
72
|
+
if (typeof force !== "number" || !Number.isFinite(force) || force < min || force > max) return null;
|
|
73
|
+
}
|
|
74
|
+
return value as unknown as Preferences;
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
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";
|
|
2
6
|
|
|
3
7
|
export interface WorkspaceTab {
|
|
4
8
|
readonly id: string;
|
|
@@ -25,7 +29,8 @@ export interface WorkspaceLayout {
|
|
|
25
29
|
readonly contextVisible: boolean;
|
|
26
30
|
readonly treeWidth: number;
|
|
27
31
|
readonly contextWidth: number;
|
|
28
|
-
readonly theme:
|
|
32
|
+
readonly theme: ThemeChoice;
|
|
33
|
+
readonly preferences: Preferences;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const MAX_TABS = 40;
|
|
@@ -46,6 +51,7 @@ export function initialLayout(): WorkspaceLayout {
|
|
|
46
51
|
treeWidth: 240,
|
|
47
52
|
contextWidth: 240,
|
|
48
53
|
theme: "system",
|
|
54
|
+
preferences: DEFAULT_PREFERENCES,
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -193,6 +199,16 @@ export function closePane(layout: WorkspaceLayout, paneId: string): WorkspaceLay
|
|
|
193
199
|
return { ...layout, panes: [{ ...kept, tabs: [...kept.tabs, ...closed.tabs] }], activePane: kept.id };
|
|
194
200
|
}
|
|
195
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
|
+
|
|
196
212
|
/** Accept a tab drop only in another existing pane. */
|
|
197
213
|
export function tabDropSource(layout: WorkspaceLayout, targetPane: string, tabId: string | null): string | null {
|
|
198
214
|
if (!layout.panes.some((pane) => pane.id === targetPane)) return null;
|
|
@@ -230,14 +246,20 @@ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
|
|
|
230
246
|
}
|
|
231
247
|
|
|
232
248
|
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;
|
|
249
|
+
if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...(Object.hasOwn(value, "preferences") ? ["preferences"] : [])])) return null;
|
|
234
250
|
if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
|
|
235
251
|
if (value["split"] !== "right" && value["split"] !== "down") return null;
|
|
236
252
|
if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
|
|
237
253
|
if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
|
|
238
254
|
if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
|
|
239
255
|
if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
|
|
240
|
-
if (value["theme"]
|
|
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
|
+
}
|
|
241
263
|
const ids = new Set<string>();
|
|
242
264
|
let count = 0;
|
|
243
265
|
let graphs = 0;
|
|
@@ -266,5 +288,23 @@ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
|
|
|
266
288
|
panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
|
|
267
289
|
}
|
|
268
290
|
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"] };
|
|
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
|
+
}) };
|
|
270
310
|
}
|