pi-weave 0.3.6 → 0.3.8

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.
@@ -344,8 +344,9 @@ export function graphColumnModel(
344
344
  // by a branch that is not on screen.
345
345
  const fills = groupColors ? groupNodeColors(reduced.nodes, edges, scheme) : undefined;
346
346
 
347
+ const graph = renderGraph(reduced.nodes, edges, layout.positions, scheme, fills);
347
348
  return {
348
- graph: renderGraph(reduced.nodes, edges, layout.positions, scheme, fills),
349
+ graph,
349
350
  highlight: highlightFor(edges, selectedId),
350
351
  key: layout.key,
351
352
  cached: layout.cached,
@@ -12,17 +12,20 @@
12
12
  * resolve it.
13
13
  */
14
14
 
15
- import { useState } from "preact/hooks";
15
+ import { useRef, useState } from "preact/hooks";
16
16
  import type { ContextModel, ContextRow, RailSectionView, RailToggles, TagGroupRow } from "../context/context.model";
17
17
  import { contextModel, emptyRailToggles, railPanelId, railSectionView, railTagsView, railToggled } from "../context/context.model";
18
18
  import type { GraphPayload } from "../../shared/wire";
19
19
  import { CONTEXT_EMPTY } from "./shell.model";
20
20
  import { Icon } from "../tree/Tree";
21
+ import { ResizeHandle } from "./ResizeHandle";
21
22
 
22
23
  export interface ContextRailProps {
23
24
  graph: GraphPayload | null;
24
25
  selectedId: string | null;
25
26
  onSelect: (id: string, newTab?: boolean) => void;
27
+ tagHeight: number;
28
+ onTagResize: (height: number) => void;
26
29
  }
27
30
 
28
31
  function Row({ row, onSelect }: { row: ContextRow; onSelect: (newTab: boolean) => void }) {
@@ -91,6 +94,7 @@ function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string, newTa
91
94
  }
92
95
 
93
96
  export function ContextRail(props: ContextRailProps) {
97
+ const tagRows = useRef<HTMLUListElement | null>(null);
94
98
  const model: ContextModel = contextModel(props.graph, props.selectedId);
95
99
  // The user's open/closed word lives in this component — it is view state,
96
100
  // not a workspace fact, so it does not cross the §1.3 bus and does not
@@ -110,11 +114,12 @@ export function ContextRail(props: ContextRailProps) {
110
114
  {model.tags.length === 0 ? null : (
111
115
  <section class="weave-ctx-group">
112
116
  <Heading view={tags} onToggle={toggleFor(tags)} />
113
- <ul class="weave-ctx-tags" id={railPanelId(tags.heading)} hidden={tags.collapsed}>
117
+ <ul class="weave-ctx-tags weave-context-tags" ref={tagRows} style={{ height: props.tagHeight }} id={railPanelId(tags.heading)} hidden={tags.collapsed}>
114
118
  {model.tags.map((tag) => (
115
119
  <Tag key={tag.tag} tag={tag} onSelect={props.onSelect} />
116
120
  ))}
117
121
  </ul>
122
+ {tags.collapsed ? null : <ResizeHandle label="Resize tags section" horizontal min={60} max={600} value={props.tagHeight} onChange={(delta) => props.onTagResize(Math.max(60, Math.min(600, (tagRows.current?.clientHeight ?? props.tagHeight) + delta)))} />}
118
123
  </section>
119
124
  )}
120
125
  </div>
@@ -0,0 +1,19 @@
1
+ import { useRef } from "preact/hooks";
2
+
3
+ export function ResizeHandle(props: { label: string; horizontal?: boolean; value: number; min: number; max: number; onChange: (delta: number) => void }) {
4
+ const last = useRef<number | null>(null);
5
+ return <div class="weave-workspace-divider" role="separator" tabIndex={0} aria-label={props.label}
6
+ aria-orientation={props.horizontal ? "horizontal" : "vertical"} aria-valuenow={Math.round(props.value)} aria-valuemin={props.min} aria-valuemax={props.max}
7
+ onPointerDown={(event) => { last.current = props.horizontal ? event.clientY : event.clientX; event.currentTarget.setPointerCapture(event.pointerId); }}
8
+ onPointerMove={(event) => {
9
+ if (last.current === null) return;
10
+ const at = props.horizontal ? event.clientY : event.clientX;
11
+ props.onChange(at - last.current); last.current = at;
12
+ }}
13
+ onPointerUp={(event) => { last.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }}
14
+ onPointerCancel={() => { last.current = null; }}
15
+ onKeyDown={(event) => {
16
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
17
+ event.preventDefault(); props.onChange(event.key === "ArrowLeft" || event.key === "ArrowUp" ? -20 : 20);
18
+ }} />;
19
+ }
@@ -18,6 +18,7 @@ import { initialWorkspaceState } from "../state";
18
18
  import { startWorkspace, watchNote } from "../workspace";
19
19
  import type { WorkspaceHandle } from "../workspace";
20
20
  import { ContextRail } from "./ContextRail";
21
+ import { ResizeHandle } from "./ResizeHandle";
21
22
  import { deeplinkSelection, formatHash } from "./deeplink.model";
22
23
  import { Settings } from "./Settings";
23
24
  import type { Preferences } from "../../shared/preferences";
@@ -112,28 +113,11 @@ function DocumentView(props: {
112
113
  })} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
113
114
  }
114
115
 
115
- function ResizeHandle(props: { label: string; horizontal?: boolean; value: number; min: number; max: number; onChange: (delta: number) => void }) {
116
- const last = useRef<number | null>(null);
117
- return <div class="weave-workspace-divider" role="separator" tabIndex={0} aria-label={props.label}
118
- aria-orientation={props.horizontal ? "horizontal" : "vertical"} aria-valuenow={Math.round(props.value)} aria-valuemin={props.min} aria-valuemax={props.max}
119
- onPointerDown={(event) => { last.current = props.horizontal ? event.clientY : event.clientX; event.currentTarget.setPointerCapture(event.pointerId); }}
120
- onPointerMove={(event) => {
121
- if (last.current === null) return;
122
- const at = props.horizontal ? event.clientY : event.clientX;
123
- props.onChange(at - last.current); last.current = at;
124
- }}
125
- onPointerUp={(event) => { last.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }}
126
- onPointerCancel={() => { last.current = null; }}
127
- onKeyDown={(event) => {
128
- if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
129
- event.preventDefault(); props.onChange(event.key === "ArrowLeft" || event.key === "ArrowUp" ? -20 : 20);
130
- }} />;
131
- }
132
-
133
116
  export function Shell(props: ShellProps) {
134
117
  const [data, setData] = useState(initialWorkspaceState);
135
118
  const [layout, setLayout] = useState(() => ({ ...initialLayout(), theme: loadTheme(localStorage) ?? "system" }));
136
119
  const [overlay, setOverlay] = useState<OverlayId>(null);
120
+ const [contextGraph, setContextGraph] = useState(false);
137
121
  const [now, setNow] = useState(Date.now);
138
122
  const [width, setWidth] = useState(props.initialWidth);
139
123
  const [scheme, setScheme] = useState(() => schemeOf(window));
@@ -143,6 +127,7 @@ export function Shell(props: ShellProps) {
143
127
  const [revision, setRevision] = useState(0);
144
128
  const [, setDraftRevision] = useState(0);
145
129
  const [graphSelection, setGraphSelection] = useState<string | null>(null);
130
+ const lastViewedDocument = useRef<string | null>(null);
146
131
  const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
147
132
  const draggedTab = useRef<string | null>(null);
148
133
  const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
@@ -157,6 +142,7 @@ export function Shell(props: ShellProps) {
157
142
  const linked = useRef(false);
158
143
  const root = useRef<HTMLDivElement | null>(null);
159
144
  const graphSlot = useRef<HTMLDivElement | null>(null);
145
+ const contextGraphSlot = useRef<HTMLDivElement | null>(null);
160
146
  const fit = useRef<(() => void) | null>(null);
161
147
  const [graphBox, setGraphBox] = useState({ left: 0, top: 0, width: 600, height: 500 });
162
148
  const pane = activePane(layout);
@@ -164,17 +150,24 @@ export function Shell(props: ShellProps) {
164
150
  const theme = themeButton(layout.theme, scheme, layout.preferences);
165
151
  const palette = effectiveScheme(layout.theme, scheme, layout.preferences);
166
152
  const graphTheme = useMemo(() => ({ theme: palette, accent: layout.preferences.accent }), [palette, layout.preferences.accent]);
167
- const selectedId = tab.kind === "graph" ? graphSelection : tabSelection(tab);
153
+ const documentId = tabSelection(tab);
154
+ const selectedId = tab.kind === "graph" ? graphSelection : documentId;
168
155
  const graphPane = layout.panes.find((group) => group.tabs.some((entry) => entry.id === group.activeTab && entry.kind === "graph"));
169
- const graphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
156
+ const fullGraphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
157
+ const contextGraphActive = contextGraph && tab.kind === "document" && !fullGraphVisible;
170
158
  const contextVisible = width < 1050 ? compactContext : layout.contextVisible;
159
+ const graphVisible = fullGraphVisible || contextGraphActive && contextVisible && documentId !== null;
171
160
  const hasGraph = layout.panes.some((group) => group.tabs.some((entry) => entry.kind === "graph"));
172
161
  const live = useRef({ layout, overlay, selectedId, graphPreviewId, select: (_id: string | null) => {} });
173
162
  const previewGraphNode = (id: string | null): void => {
174
163
  live.current.graphPreviewId = id;
175
164
  setGraphPreviewId(id);
176
165
  };
177
- useEffect(() => { if (!graphVisible) previewGraphNode(null); }, [graphVisible]);
166
+ useEffect(() => { previewGraphNode(null); }, [graphVisible, contextGraphActive, documentId]);
167
+ useEffect(() => {
168
+ if (documentId !== null) { lastViewedDocument.current = documentId; setGraphSelection(documentId); }
169
+ else if (tab.kind === "graph") setGraphSelection(lastViewedDocument.current);
170
+ }, [tab.id, tab.kind, documentId]);
178
171
 
179
172
  const change = (next: WorkspaceLayout): void => {
180
173
  if (mayRemoveDrafts(drafts, live.current.layout, next, () => window.confirm(DISCARD_PROMPT))) setLayout(next);
@@ -182,16 +175,25 @@ export function Shell(props: ShellProps) {
182
175
  const select = (id: string | null, newTab = false, paneId = layout.activePane, keepSidebar = false): void => {
183
176
  const next = (id === null ? openDocument : openWithPreferences)(layout, id, { newTab, paneId });
184
177
  change(width < 850 && !keepSidebar ? { ...next, treeVisible: false } : next);
185
- setCompactContext(false);
178
+ if (!contextGraph) setCompactContext(false);
179
+ };
180
+ const toggleContextGraph = (): void => {
181
+ const showing = !(contextGraphActive && contextVisible);
182
+ setContextGraph(showing);
183
+ if (showing) {
184
+ setLayout((current) => ({ ...focusDocument(current), contextVisible: true }));
185
+ setCompactContext(true);
186
+ }
186
187
  };
187
188
  const showGraph = (): void => setLayout((current) => ({ ...openGraph(current), ...(width < 850 ? { treeVisible: false } : {}) }));
188
189
  const openGraphNode = (id: string): void => {
189
190
  previewGraphNode(null);
191
+ if (contextGraphActive) { select(id, true); return; }
190
192
  const other = layout.panes.find((group) => group.id !== graphPane?.id);
191
193
  select(id, true, other?.id ?? graphPane?.id ?? layout.activePane);
192
194
  };
193
195
  const selectGraph = (id: string | null): void => {
194
- setGraphSelection(id);
196
+ if (!contextGraphActive) setGraphSelection(id);
195
197
  if (graphClickOpensTab(live.current.graphPreviewId, id)) openGraphNode(id);
196
198
  else previewGraphNode(id);
197
199
  };
@@ -229,7 +231,10 @@ export function Shell(props: ShellProps) {
229
231
  if (value.info) setInfo(value.info);
230
232
  const restored = parseWorkspaceLayout(value.layout);
231
233
  if (restored) {
232
- setLayout(restoreWorkspace(restored));
234
+ const restoredLayout = restoreWorkspace(restored);
235
+ setLayout(restoredLayout);
236
+ lastViewedDocument.current = tabSelection(activeTab(focusDocument(restoredLayout)));
237
+ setGraphSelection(lastViewedDocument.current);
233
238
  setVisits(restored.panes.flatMap((pane) => pane.tabs.flatMap((tab) => tab.history)).reduce<readonly string[]>(recordVisit, []));
234
239
  }
235
240
  } else setPersistError(true);
@@ -319,7 +324,7 @@ export function Shell(props: ShellProps) {
319
324
  }, [layout.panes.map((group) => group.activeTab).join(","), layout.activePane, width, ready, data.graph?.stamp,
320
325
  layout.treeVisible, layout.treeWidth, layout.contextVisible, layout.contextWidth, layout.ratio, layout.split]);
321
326
  useLayoutEffect(() => {
322
- const slot = graphSlot.current;
327
+ const slot = contextGraphActive ? contextGraphSlot.current : graphSlot.current;
323
328
  const parent = root.current;
324
329
  if (!graphVisible || !slot || !parent) return;
325
330
  const measure = (): void => {
@@ -329,7 +334,7 @@ export function Shell(props: ShellProps) {
329
334
  measure();
330
335
  const observer = new ResizeObserver(measure); observer.observe(slot); observer.observe(parent);
331
336
  return () => observer.disconnect();
332
- }, [graphVisible, layout, width]);
337
+ }, [contextGraphActive, graphVisible, layout, width]);
333
338
 
334
339
  const rememberScroll = (tabId: string, scroll: number): void => setLayout((current) => ({ ...current,
335
340
  panes: current.panes.map((group) => ({ ...group, tabs: group.tabs.map((entry) => entry.id === tabId ? { ...entry, scroll } : entry) })),
@@ -435,6 +440,7 @@ export function Shell(props: ShellProps) {
435
440
  <nav class="weave-ribbon" aria-label="Workspace tools">
436
441
  <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
437
442
  <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
443
+ <button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><svg viewBox="0 0 20 20" width={18} height={18} fill="none" stroke="currentColor" stroke-width={1.5} aria-hidden="true"><rect x="2" y="2" width="16" height="16" rx="3" /><circle cx="10" cy="10" r="4" /></svg></button>
438
444
  <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}>
439
445
  <svg viewBox="0 0 20 20" width={16} height={16} fill="none" stroke="currentColor" stroke-width={1.7} aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" /></svg>
440
446
  </button>
@@ -455,8 +461,14 @@ export function Shell(props: ShellProps) {
455
461
  setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
456
462
  }} /> : null}{renderPane(group)}</>)}
457
463
  </main>
458
- <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div><ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} /></aside></div>
459
- {hasGraph ? <div class="weave-graph-host" onDragOver={(event) => { if (graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (graphPane) tabDrop(graphPane.id, event); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }} onPointerDown={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} onFocusCapture={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} aria-hidden={!graphVisible} style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
464
+ <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div>{contextGraphActive ? <section class="weave-context-graph-section" aria-label="Note connections"><div class="weave-context-graph-heading"><strong>Note graph</strong><button type="button" aria-label="Hide note graph" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle note graph"]')?.focus(); setContextGraph(false); }}>×</button></div><div class="weave-context-graph-slot" ref={contextGraphSlot} style={{ height: layout.contextGraphHeight }}>{documentId === null ? <p>Select a note to see its connections.</p> : null}</div><ResizeHandle label="Resize note graph" horizontal min={120} max={800} value={graphBox.height} onChange={(delta) => setLayout((value) => ({ ...value, contextGraphHeight: Math.max(120, Math.min(800, (contextGraphSlot.current?.clientHeight ?? value.contextGraphHeight) + delta)) }))} /></section> : null}<ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} tagHeight={layout.contextTagsHeight} onTagResize={(contextTagsHeight) => setLayout((value) => ({ ...value, contextTagsHeight }))} /></aside></div>
465
+ {hasGraph || contextGraph ? <div class={`weave-graph-host${contextGraphActive ? " weave-context-graph-host" : ""}`}
466
+ onDragOver={(event) => { if (!contextGraphActive && graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (!contextGraphActive && graphPane) tabDrop(graphPane.id, event); }}
467
+ onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
468
+ onPointerDown={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
469
+ onFocusCapture={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
470
+ aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
471
+ style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
460
472
  <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} />
461
473
  </div> : null}
462
474
  </div>
@@ -1,6 +1,6 @@
1
1
  /** Tabbed workspace furniture; the graph keeps its existing palette and renderer. */
2
2
  export const WORKSPACE_CSS = `
3
- .weave-workbench{position:relative;display:flex;min-height:0;overflow:hidden;background:var(--weave-page)}
3
+ .weave-workbench{position:relative;isolation:isolate;display:flex;min-height:0;overflow:hidden;background:var(--weave-page)}
4
4
  .weave-workbench button{font:inherit;color:inherit;cursor:pointer}
5
5
  .weave-workbench button:focus-visible,.weave-workbench summary:focus-visible{outline:2px solid var(--weave-accent);outline-offset:-2px}
6
6
  .weave-workbench button:disabled{opacity:.35;cursor:default}
@@ -93,6 +93,16 @@ export const WORKSPACE_CSS = `
93
93
  .weave-sidebar-context .weave-ctx-link{padding-top:7px;padding-bottom:7px}
94
94
  .weave-graph-host{position:absolute;z-index:2;display:flex;background:var(--weave-bg)}
95
95
  .weave-graph-host>.weave-graph{width:100%;height:100%;flex:1}
96
+ .weave-context-graph-section{flex:none;border-bottom:1px solid var(--weave-line)}
97
+ .weave-context-graph-heading{display:flex;align-items:center;justify-content:space-between;padding:4px 12px;font-size:var(--weave-px-caption);color:var(--weave-dim)}
98
+ .weave-context-graph-heading button{border:0;background:none;padding:2px 6px;font-size:18px}
99
+ .weave-context-graph-slot{max-height:calc(100dvh - 180px);min-height:120px}
100
+ .weave-context-tags{min-height:60px;max-height:60vh;overflow:auto}
101
+ .weave-context-graph-slot>p{padding:16px;margin:0;color:var(--weave-dim);font-size:var(--weave-px-row)}
102
+ .weave-context-graph-host{z-index:11}
103
+ .weave-context-graph-host .weave-graph-legend{display:none}
104
+ .weave-context-graph-host .weave-graph-preview{bottom:56px;padding:8px 28px 8px 8px}
105
+ .weave-context-graph-host .weave-graph-preview-title{font-size:var(--weave-px-caption)}
96
106
  .weave-graph-preview{pointer-events:none;position:absolute;z-index:2;right:8px;bottom:64px;width:min(280px,calc(100% - 16px));box-sizing:border-box;display:flex;flex-direction:column;gap:8px;padding:10px 34px 10px 10px;background:var(--weave-panel);border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);box-shadow:0 6px 22px #0003}
97
107
  .weave-graph-preview-info{display:flex;align-items:center;gap:8px;min-width:0}
98
108
  .weave-graph-preview-copy{display:flex;flex-direction:column;min-width:0;gap:2px}
@@ -29,6 +29,8 @@ export interface WorkspaceLayout {
29
29
  readonly contextVisible: boolean;
30
30
  readonly treeWidth: number;
31
31
  readonly contextWidth: number;
32
+ readonly contextGraphHeight: number;
33
+ readonly contextTagsHeight: number;
32
34
  readonly theme: ThemeChoice;
33
35
  readonly preferences: Preferences;
34
36
  }
@@ -50,6 +52,8 @@ export function initialLayout(): WorkspaceLayout {
50
52
  contextVisible: true,
51
53
  treeWidth: 240,
52
54
  contextWidth: 240,
55
+ contextGraphHeight: 260,
56
+ contextTagsHeight: 180,
53
57
  theme: "system",
54
58
  preferences: DEFAULT_PREFERENCES,
55
59
  };
@@ -184,12 +188,8 @@ export function navigateTab(layout: WorkspaceLayout, paneId: string, delta: -1 |
184
188
 
185
189
  export function splitPane(layout: WorkspaceLayout, direction: "right" | "down"): WorkspaceLayout {
186
190
  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 };
191
+ const moved = moveTab(layout, layout.activePane, activeTab(layout).id);
192
+ return moved === layout ? layout : { ...moved, split: direction };
193
193
  }
194
194
 
195
195
  export function closePane(layout: WorkspaceLayout, paneId: string): WorkspaceLayout {
@@ -246,13 +246,17 @@ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
246
246
  }
247
247
 
248
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;
249
+ if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...["preferences", "contextGraphHeight", "contextTagsHeight"].filter(key => Object.hasOwn(value, key))])) return null;
250
250
  if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
251
251
  if (value["split"] !== "right" && value["split"] !== "down") return null;
252
252
  if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
253
253
  if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
254
254
  if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
255
255
  if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
256
+ const contextGraphHeight = Object.hasOwn(value, "contextGraphHeight") ? value["contextGraphHeight"] : 260;
257
+ const contextTagsHeight = Object.hasOwn(value, "contextTagsHeight") ? value["contextTagsHeight"] : 180;
258
+ if (typeof contextGraphHeight !== "number" || !Number.isFinite(contextGraphHeight) || contextGraphHeight < 120 || contextGraphHeight > 800) return null;
259
+ if (typeof contextTagsHeight !== "number" || !Number.isFinite(contextTagsHeight) || contextTagsHeight < 60 || contextTagsHeight > 600) return null;
256
260
  if (!isThemeChoice(value["theme"])) return null;
257
261
  let preferences = Object.hasOwn(value, "preferences") ? parsePreferences(value["preferences"]) : DEFAULT_PREFERENCES;
258
262
  if (preferences === null) return null;
@@ -288,7 +292,7 @@ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
288
292
  panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
289
293
  }
290
294
  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 };
295
+ 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"], contextGraphHeight, contextTagsHeight, theme: value["theme"], preferences };
292
296
  }
293
297
 
294
298
  /** Startup choice resets navigation only; appearance and sidebar preferences survive. */