pi-weave 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,292 +1,409 @@
1
- /**
2
- * The workspace shell.
3
- *
4
- * Header, resizable columns, context rail, status bar. This component holds
5
- * the wiring and nothing else — every value it renders comes from pure
6
- * functions in the shell models,
7
- * and the fetch/poll loop is `workspace.ts`. What is left here is hooks:
8
- * state in, callbacks out.
9
- *
10
- * The main effects are one-liners over injected units:
11
- *
12
- * 1. **mount** — `startWorkspace` fetches the graph and starts polling;
13
- * the returned `stop` is the cleanup, so a hot reload cannot leak a timer.
14
- * 2. **keys** — `watchKeys` attaches the one global `keydown` listener
15
- * (§11 P4). Its context is read through `live`, because a listener
16
- * registered at mount outlives every render and a captured overlay flag
17
- * would let `⌘K` stack a palette on top of itself.
18
- */
19
-
20
- import { useEffect, useMemo, useRef, useState } from "preact/hooks";
1
+ /** Shared browser/desktop workspace: one data source, two optional tab groups. */
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
3
+ import type { GraphPayload, NotePayload } from "../../shared/wire";
4
+ import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, moveTab, navigateTab, openDocument, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
5
+ import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
21
6
  import { fetchJson } from "../api.dom";
22
7
  import { openNote, saveNote } from "../api";
23
- import { DISCARD_PROMPT } from "../note/note.model";
24
- import type { ColorScheme } from "../graph/graph.model";
8
+ import { Graph } from "../graph/Graph";
25
9
  import { schemeOf, watchScheme } from "../graph/scheme";
26
10
  import { createSigmaRenderer } from "../graph/renderer.dom";
11
+ import { Note } from "../note/Note";
12
+ import { createDraftStore, mayMutateDrafts, mayRemoveDrafts } from "../note/drafts";
13
+ import type { DraftStore } from "../note/drafts";
14
+ import { DISCARD_PROMPT } from "../note/note.model";
27
15
  import { SearchPalette } from "../search/SearchPalette";
28
- import { restoreSelection, saveSelection } from "../selection.storage";
16
+ import { Icon, Tree } from "../tree/Tree";
29
17
  import { initialWorkspaceState } from "../state";
30
- import type { WorkspaceState } from "../state";
18
+ import { startWorkspace, watchNote } from "../workspace";
31
19
  import type { WorkspaceHandle } from "../workspace";
32
- import { startWorkspace } from "../workspace";
33
- import { Columns } from "./Columns";
20
+ import { ContextRail } from "./ContextRail";
34
21
  import { deeplinkSelection, formatHash } from "./deeplink.model";
35
- import { dividerHandlers } from "./drag.model";
36
- import { Header } from "./Header";
37
22
  import { HelpOverlay } from "./HelpOverlay";
38
23
  import { watchKeys } from "./keys";
39
- import { focusSelector, runShellAction } from "./keys.model";
24
+ import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
40
25
  import { StatusBar } from "./StatusBar";
41
- import type { LayoutState } from "./layout.model";
42
- import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.model";
43
26
  import type { OverlayId } from "./shell.model";
44
- import { TICK_MS, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
27
+ import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
45
28
  import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
46
- import type { ThemeChoice } from "./theme.model";
47
29
 
48
- /**
49
- * Write the choice onto `<html>`, so the sheet's attribute branch and the
50
- * media query agree on who is in charge (see `theme.model.ts`'s header).
51
- */
52
- function applyThemeAttr(choice: ReturnType<typeof themeAttr>): void {
53
- if (choice === null) delete document.documentElement.dataset.weaveTheme;
54
- else document.documentElement.dataset.weaveTheme = choice;
30
+ export interface ShellProps { cwd: string; initialWidth: number; platform: string; tuner?: boolean }
31
+
32
+ function titleOf(tab: WorkspaceTab, graph: GraphPayload | null): string {
33
+ if (tab.kind === "graph") return "Graph view";
34
+ const id = tabSelection(tab);
35
+ return id === null ? "New tab" : graph?.model.nodes.find((node) => node.id === id)?.label ?? id.replace(/^[^:]+:/, "");
36
+ }
37
+
38
+ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | null; selectedId: string | null; onSelect: (id: string, newTab?: boolean) => void }) {
39
+ const [snapshot] = useState(props.visits);
40
+ return <div class="weave-recents">{recentEntries(snapshot, props.graph, props.selectedId).map((entry) => <button type="button" key={entry.id}
41
+ aria-current={entry.selected ? "page" : undefined} title={entry.label}
42
+ onClick={(event) => props.onSelect(entry.id, event.metaKey || event.ctrlKey)}>
43
+ <span class="weave-kind"><Icon name={entry.icon} class="weave-icon" /></span><span class="weave-recent-label">{entry.label}</span>
44
+ </button>)}</div>;
45
+ }
46
+
47
+ /** A tab view can unmount; the shared draft store outlives it. */
48
+ function DocumentView(props: {
49
+ tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
50
+ onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
51
+ now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void;
52
+ }) {
53
+ const id = tabSelection(props.tab);
54
+ const slug = id?.startsWith("note:") ? id.slice(5) : null;
55
+ const [loaded, setLoaded] = useState<{ slug: string; note: NotePayload | null; failed: boolean; version: number } | null>(null);
56
+ const element = useRef<HTMLDivElement | null>(null);
57
+ const savedScroll = useRef(props.tab.scroll);
58
+ useEffect(() => {
59
+ if (slug === null) return;
60
+ const version = props.drafts.nextLoadVersion();
61
+ return watchNote(fetchJson, slug, (result) => {
62
+ setLoaded({ slug, note: result.ok ? result.data : null, failed: !result.ok, version });
63
+ });
64
+ }, [slug, props.graph?.stamp, props.revision]);
65
+ const payload = loaded?.slug === slug ? loaded : null;
66
+ useLayoutEffect(() => {
67
+ const note = element.current?.querySelector<HTMLElement>(".weave-note");
68
+ if (note !== null && note !== undefined) note.scrollTop = savedScroll.current;
69
+ }, [id, payload?.note?.note.slug]);
70
+ if (id === null) return <div class="weave-welcome">
71
+ <span class="weave-welcome-mark" aria-hidden="true">✳</span><h1>Your knowledge, connected.</h1>
72
+ <p>Open a note from the sidebar, find something you remember, or follow a connection.</p>
73
+ <div><button type="button" onClick={props.onSearch}>Search workspace <kbd>⌘K</kbd></button><button type="button" onClick={props.onGraph}>Explore graph</button></div>
74
+ </div>;
75
+ const missing = props.graph !== null && !props.graph.model.nodes.some((node) => node.id === id);
76
+ if (missing || (payload?.failed && props.drafts.isDirty(slug ?? ""))) {
77
+ const draft = slug === null ? null : props.drafts.get(slug);
78
+ if (draft !== null && slug !== null && props.drafts.isDirty(slug)) return <div class="weave-welcome">
79
+ <h2>{missing ? "This item is no longer here" : "This note could not be loaded"}</h2>
80
+ <p>Your unsaved draft is preserved below. Select and copy it before closing this workspace. {missing ? "Saving cannot restore or recreate the missing note." : "Reopen this tab to retry loading the note."}</p>
81
+ <label class="weave-note-meta" for={`missing-draft-${props.tab.id}`}>Unsaved draft (read-only)</label>
82
+ <textarea id={`missing-draft-${props.tab.id}`} class="weave-note-editor" aria-label="Unsaved draft for missing note" rows={16} readOnly value={draft.body} />
83
+ <button type="button" onClick={props.onSearch}>Find another note</button>
84
+ </div>;
85
+ return <div class="weave-welcome"><h2>This item is no longer here</h2><p>It may have been moved or deleted. Your other tabs are still available.</p><button type="button" onClick={props.onSearch}>Find another note</button></div>;
86
+ }
87
+ return <div class="weave-document" ref={element} onScrollCapture={(event) => {
88
+ const target = event.target as HTMLElement;
89
+ if (target.classList.contains("weave-note")) { savedScroll.current = target.scrollTop; props.onScroll(target.scrollTop); }
90
+ }}><Note note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
91
+ onSelect={props.onSelect} onOpen={(noteSlug) => void openNote(fetchJson, noteSlug).then((result) => {
92
+ if (!result.ok) window.alert(result.message); else if (!result.data.opened) window.alert("Could not open the note in an editor.");
93
+ })} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
55
94
  }
56
95
 
57
- export interface ShellProps {
58
- /** From the page bootstrap. Shown in the status bar. */
59
- cwd: string;
60
- /** `window.innerWidth` at mount, used for the first breakpoint/layout. */
61
- initialWidth: number;
62
- /** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
63
- platform: string;
64
- /**
65
- * `location.search` carried `?sliders=1` — open the hidden force tuner over
66
- * the graph column (docs/weave-workspace.md §15.7). Resolved by `slidersFlag`
67
- * in `main.tsx`; optional so every existing caller and test keeps its shape.
68
- */
69
- tuner?: boolean;
96
+ function ResizeHandle(props: { label: string; horizontal?: boolean; value: number; min: number; max: number; onChange: (delta: number) => void }) {
97
+ const last = useRef<number | null>(null);
98
+ return <div class="weave-workspace-divider" role="separator" tabIndex={0} aria-label={props.label}
99
+ aria-orientation={props.horizontal ? "horizontal" : "vertical"} aria-valuenow={Math.round(props.value)} aria-valuemin={props.min} aria-valuemax={props.max}
100
+ onPointerDown={(event) => { last.current = props.horizontal ? event.clientY : event.clientX; event.currentTarget.setPointerCapture(event.pointerId); }}
101
+ onPointerMove={(event) => {
102
+ if (last.current === null) return;
103
+ const at = props.horizontal ? event.clientY : event.clientX;
104
+ props.onChange(at - last.current); last.current = at;
105
+ }}
106
+ onPointerUp={(event) => { last.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }}
107
+ onPointerCancel={() => { last.current = null; }}
108
+ onKeyDown={(event) => {
109
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
110
+ event.preventDefault(); props.onChange(event.key === "ArrowLeft" || event.key === "ArrowUp" ? -20 : 20);
111
+ }} />;
70
112
  }
71
113
 
72
114
  export function Shell(props: ShellProps) {
73
- const [workspaceState, setWorkspaceState] = useState<WorkspaceState>(initialWorkspaceState);
115
+ const [data, setData] = useState(initialWorkspaceState);
116
+ const [layout, setLayout] = useState(() => ({ ...initialLayout(), theme: loadTheme(localStorage) ?? "system" }));
74
117
  const [overlay, setOverlay] = useState<OverlayId>(null);
118
+ const [now, setNow] = useState(Date.now);
75
119
  const [width, setWidth] = useState(props.initialWidth);
76
- const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
77
- // The theme: what the user picked, and what the OS is currently saying. Two
78
- // states because they answer different questions — `theme` changes on a
79
- // button press or the `t` key, `systemScheme` on an OS flip while the user
80
- // is in system mode — and `effectiveScheme` is where the two resolve.
81
- const [theme, setTheme] = useState<ThemeChoice>(() => loadTheme(localStorage) ?? "system");
82
- const [systemScheme, setSystemScheme] = useState<ColorScheme>(() => schemeOf(window));
120
+ const [scheme, setScheme] = useState(() => schemeOf(window));
121
+ const [ready, setReady] = useState(false);
122
+ const [persistError, setPersistError] = useState(false);
123
+ const [revision, setRevision] = useState(0);
124
+ const [, setDraftRevision] = useState(0);
125
+ const [graphSelection, setGraphSelection] = useState<string | null>(null);
126
+ const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
127
+ const draggedTab = useRef<string | null>(null);
128
+ const [dropPane, setDropPane] = useState<string | null>(null);
129
+ const [recentMode, setRecentMode] = useState(false);
130
+ const [visits, setVisits] = useState<readonly string[]>([]);
131
+ const [compactContext, setCompactContext] = useState(false);
132
+ const [drafts] = useState(createDraftStore);
83
133
  const workspace = useRef<WorkspaceHandle | null>(null);
84
- // Filled by the graph column at mount, cleared on unmount. The global `g`
85
- // key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
134
+ const viewId = useRef<string | null>(null);
135
+ const bootHash = useRef(location.hash);
136
+ const linked = useRef(false);
137
+ const root = useRef<HTMLDivElement | null>(null);
138
+ const graphSlot = useRef<HTMLDivElement | null>(null);
86
139
  const fit = useRef<(() => void) | null>(null);
87
- // Filled by the note column while an editor is open: the exits that destroy
88
- // a draft are the shell's events, not the column's.
89
- const editor = useRef<{ dirty(): boolean; discard(): void } | null>(null);
90
- /**
91
- * Confirm abandoning an open draft, closing it if the user agrees. `false`
92
- * means keep editing. Closing here is what stops a tree mutation asking
93
- * twice — the draft is gone before the selection that follows it.
94
- */
95
- const mayDiscard = (): boolean => {
96
- const open = editor.current;
97
- if (open === null || !open.dirty()) return true;
98
- if (!window.confirm(DISCARD_PROMPT)) return false;
99
- open.discard();
100
- return true;
101
- };
102
- /** Every selection change routes through here, so no exit skips the guard. */
103
- const select = (id: string | null): void => {
104
- if (!mayDiscard()) return;
105
- void workspace.current?.select(id);
140
+ const [graphBox, setGraphBox] = useState({ left: 0, top: 0, width: 600, height: 500 });
141
+ const pane = activePane(layout);
142
+ const tab = activeTab(layout);
143
+ const theme = themeButton(layout.theme);
144
+ const selectedId = tab.kind === "graph" ? graphSelection : tabSelection(tab);
145
+ const graphPane = layout.panes.find((group) => group.tabs.some((entry) => entry.id === group.activeTab && entry.kind === "graph"));
146
+ const graphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
147
+ const contextVisible = width < 1050 ? compactContext : layout.contextVisible;
148
+ const hasGraph = layout.panes.some((group) => group.tabs.some((entry) => entry.kind === "graph"));
149
+ const live = useRef({ layout, overlay, selectedId, graphPreviewId, select: (_id: string | null) => {} });
150
+ const previewGraphNode = (id: string | null): void => {
151
+ live.current.graphPreviewId = id;
152
+ setGraphPreviewId(id);
106
153
  };
107
- // The global key listener reads through this so a handler registered at
108
- // mount still sees the current overlay.
109
- const live = useRef({ overlay, selectedId: workspaceState.selectedId, layout, width });
110
- live.current = { overlay, selectedId: workspaceState.selectedId, layout, width };
154
+ useEffect(() => { if (!graphVisible) previewGraphNode(null); }, [graphVisible]);
111
155
 
156
+ const change = (next: WorkspaceLayout): void => {
157
+ if (mayRemoveDrafts(drafts, live.current.layout, next, () => window.confirm(DISCARD_PROMPT))) setLayout(next);
158
+ };
159
+ const select = (id: string | null, newTab = false, paneId = layout.activePane, keepSidebar = false): void => {
160
+ const next = openDocument(layout, id, { newTab, paneId });
161
+ change(width < 850 && !keepSidebar ? { ...next, treeVisible: false } : next);
162
+ setCompactContext(false);
163
+ };
164
+ const showGraph = (): void => setLayout((current) => ({ ...openGraph(current), ...(width < 850 ? { treeVisible: false } : {}) }));
165
+ const openGraphNode = (id: string): void => {
166
+ previewGraphNode(null);
167
+ const other = layout.panes.find((group) => group.id !== graphPane?.id);
168
+ select(id, true, other?.id ?? graphPane?.id ?? layout.activePane);
169
+ };
170
+ const selectGraph = (id: string | null): void => {
171
+ setGraphSelection(id);
172
+ if (graphClickOpensTab(live.current.graphPreviewId, id)) openGraphNode(id);
173
+ else previewGraphNode(id);
174
+ };
175
+ live.current = { layout, overlay, selectedId, graphPreviewId, select };
176
+ const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
177
+ const save = async (slug: string, body: string): Promise<boolean> => {
178
+ const result = await saveNote(fetchJson, slug, body);
179
+ if (!result.ok) { window.alert(result.message); return false; }
180
+ setRevision((value) => value + 1); workspace.current?.refresh(); return true;
181
+ };
182
+ useEffect(() => drafts.subscribe(() => setDraftRevision((value) => value + 1)), [drafts]);
112
183
  useEffect(() => {
113
- const handle = startWorkspace({ fetch: fetchJson, state: workspaceState, setState: setWorkspaceState });
114
- workspace.current = handle;
115
- return () => handle.stop();
184
+ workspace.current = startWorkspace({ fetch: fetchJson, state: initialWorkspaceState(), setState: setData });
185
+ return () => workspace.current?.stop();
116
186
  }, []);
117
-
118
187
  useEffect(() => {
119
- const onResize = (): void => setWidth(window.innerWidth);
120
- window.addEventListener("resize", onResize);
121
- return () => window.removeEventListener("resize", onResize);
188
+ let current = true;
189
+ void (async () => {
190
+ try {
191
+ const response = await fetchJson("/api/workspace-state");
192
+ const value = await response.json() as { viewId?: unknown; layout?: unknown };
193
+ if (!current) return;
194
+ if (response.ok && typeof value.viewId === "string") {
195
+ viewId.current = value.viewId;
196
+ const restored = parseWorkspaceLayout(value.layout);
197
+ if (restored) {
198
+ setLayout(restored);
199
+ setVisits(restored.panes.flatMap((pane) => pane.tabs.flatMap((tab) => tab.history)).reduce<readonly string[]>(recordVisit, []));
200
+ }
201
+ } else setPersistError(true);
202
+ } catch { if (current) setPersistError(true); }
203
+ finally { if (current) setReady(true); }
204
+ })();
205
+ return () => { current = false; };
122
206
  }, []);
123
-
124
- // §1.3 continuity: a reload keeps the note you were reading. Saving is
125
- // gated on the restore decision so the mount-time `null` cannot wipe the
126
- // saved id before the first graph arrives to validate it against.
127
- const selectionRestored = useRef(false);
128
- // The deep link is read once, at mount, into a ref — the URL is an input
129
- // to boot only, and the hash-write effect below must never clear a link
130
- // this effect has not read yet.
131
- const bootHash = useRef(location.hash);
132
207
  useEffect(() => {
133
- if (!selectionRestored.current) return;
134
- saveSelection(localStorage, workspaceState.selectedId);
135
- // replaceState, not pushState: the address bar mirrors the note on
136
- // screen, and reading three notes is not three history entries. The
137
- // *string* is the model's (`formatHash`); this is the write itself.
138
- history.replaceState(null, "", formatHash(workspaceState.selectedId));
139
- }, [workspaceState.selectedId]);
208
+ if (!ready || data.graph === null || linked.current) return;
209
+ linked.current = true;
210
+ const id = deeplinkSelection(bootHash.current, data.graph);
211
+ if (id !== null) setLayout((current) => openDocument(current, id));
212
+ }, [ready, data.graph]);
213
+ useEffect(() => { if (ready) setVisits((previous) => recordVisit(previous, selectedId)); }, [ready, selectedId]);
140
214
  useEffect(() => {
141
- if (selectionRestored.current || workspaceState.graph === null) return;
142
- selectionRestored.current = true;
143
- if (workspaceState.selectedId !== null) return;
144
- // A link wins over storage: the address bar is an explicit instruction,
145
- // the saved note is a habit. A link to a note the graph does not hold is
146
- // refused (see `deeplink.model.ts`) and continuity falls through.
147
- const linked = deeplinkSelection(bootHash.current, workspaceState.graph);
148
- if (linked !== null) void workspace.current?.select(linked);
149
- else {
150
- const saved = restoreSelection(workspaceState.graph, localStorage);
151
- if (saved !== null) void workspace.current?.select(saved);
152
- }
153
- }, [workspaceState.graph]);
154
-
155
- // The relative-time clock. Every "8h ago" in the tree and the note meta is
156
- // computed from a `now` stamped per render, and a resting workspace never
157
- // re-renders on its own — so the minutes used to go stale until the next
158
- // poll. One interval at `TICK_MS` (the reasoning is in `shell.model.ts`);
159
- // `setNow` with a new value re-renders, and a re-render is all the copy
160
- // needs to catch up.
161
- const [now, setNow] = useState(() => Date.now());
215
+ if (!ready) return;
216
+ history.replaceState(null, "", formatHash(selectedId));
217
+ const attr = themeAttr(layout.theme);
218
+ if (attr === null) delete document.documentElement.dataset.weaveTheme;
219
+ else document.documentElement.dataset.weaveTheme = attr;
220
+ saveTheme(localStorage, layout.theme);
221
+ }, [ready, selectedId, layout.theme]);
222
+ const pendingSave = useRef(Promise.resolve());
162
223
  useEffect(() => {
163
- const tick = setInterval(() => setNow(Date.now()), TICK_MS);
164
- return () => clearInterval(tick);
165
- }, []);
166
-
167
- // The theme's two effects: the attribute decides what the sheet paints, the
168
- // save decides what a reload restores. Both keyed on the choice alone — a
169
- // system-scheme flip touches neither, because the media query repaints the
170
- // stylesheet by itself and the shell only re-resolves the graph's scheme.
171
- useEffect(() => applyThemeAttr(themeAttr(theme)), [theme]);
172
- useEffect(() => void saveTheme(localStorage, theme), [theme]);
173
- // Live OS flips, needed only in system mode but harmless always: the
174
- // stylesheet follows the OS on its own; this is what lets the *graph* (and
175
- // the header button, which shows the resolved scheme nowhere — it shows the
176
- // choice) keep up while the choice is "system".
177
- useEffect(() => watchScheme(window, setSystemScheme), []);
178
-
179
- // The other exit. No custom message (browsers ignore it), but `returnValue`
180
- // as well as `preventDefault` — the only spelling all of them honour.
224
+ if (!ready || !viewId.current) return;
225
+ const id = viewId.current;
226
+ const timer = window.setTimeout(() => {
227
+ pendingSave.current = pendingSave.current.then(async () => {
228
+ try {
229
+ const response = await fetchJson("/api/workspace-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ viewId: id, layout }) });
230
+ setPersistError(!response.ok);
231
+ } catch { setPersistError(true); }
232
+ });
233
+ }, 350);
234
+ return () => window.clearTimeout(timer);
235
+ }, [layout, ready]);
181
236
  useEffect(() => {
182
- const guard = (event: BeforeUnloadEvent): void => {
183
- if (editor.current?.dirty() !== true) return;
184
- event.preventDefault();
185
- event.returnValue = "";
237
+ const tick = window.setInterval(() => setNow(Date.now()), TICK_MS);
238
+ const resize = (): void => setWidth(window.innerWidth);
239
+ window.addEventListener("resize", resize);
240
+ const stopScheme = watchScheme(window, setScheme);
241
+ const dismissMenus = (event: PointerEvent): void => {
242
+ for (const menu of document.querySelectorAll<HTMLDetailsElement>(".weave-pane-menu[open]")) {
243
+ if (!menu.contains(event.target as Node)) menu.open = false;
244
+ }
245
+ };
246
+ document.addEventListener("pointerdown", dismissMenus);
247
+ const unload = (event: BeforeUnloadEvent): void => {
248
+ if (!drafts.dirtySlugs().length) return;
249
+ event.preventDefault(); event.returnValue = "";
250
+ };
251
+ const persist = (): void => {
252
+ if (viewId.current) void fetch("/api/workspace-state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ viewId: viewId.current, layout: live.current.layout }), credentials: "same-origin", keepalive: true }).catch(() => {});
186
253
  };
187
- window.addEventListener("beforeunload", guard);
188
- return () => window.removeEventListener("beforeunload", guard);
254
+ window.addEventListener("pagehide", persist);
255
+ window.addEventListener("beforeunload", unload);
256
+ return () => { window.clearInterval(tick); window.removeEventListener("resize", resize); window.removeEventListener("beforeunload", unload); window.removeEventListener("pagehide", persist); document.removeEventListener("pointerdown", dismissMenus); stopScheme(); };
189
257
  }, []);
190
-
191
- useEffect(
192
- () =>
193
- watchKeys(document, {
194
- context: () => ({ overlay: live.current.overlay, hasSelection: live.current.selectedId !== null }),
195
- run: (action) =>
196
- runShellAction(action, {
197
- setOverlay,
198
- focusSelector: (selector) => focusSelector(document, selector),
199
- fitGraph: () => fit.current?.(),
200
- clearSelection: () => select(null),
201
- cycleTheme: () => setTheme((current) => cycleTheme(current)),
202
- }),
203
- }),
204
- [],
205
- );
206
-
207
- const resolved = useMemo(() => resolveColumns(layout, width, breakpointFor(width)), [layout, width]);
208
- const drag = useMemo(() => dividerHandlers({
209
- layout: () => live.current.layout,
210
- width: () => live.current.width,
211
- setLayout,
212
- persist: (next) => void saveLayout(localStorage, next),
258
+ useEffect(() => watchKeys(document, {
259
+ context: () => ({ overlay: live.current.overlay, hasSelection: live.current.selectedId !== null }),
260
+ run: (action) => runShellAction(action, {
261
+ setOverlay,
262
+ fitGraph: () => fit.current?.(),
263
+ clearSelection: () => {
264
+ if (activeTab(live.current.layout).kind === "graph") { setGraphSelection(null); previewGraphNode(null); }
265
+ else live.current.select(null);
266
+ },
267
+ closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
268
+ cycleTheme: () => setLayout((current) => ({ ...current, theme: cycleTheme(current.theme) })),
269
+ focusSelector: (selector) => {
270
+ if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
271
+ else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
272
+ else setLayout(focusDocument);
273
+ requestAnimationFrame(() => {
274
+ if (selector === COLUMN_FOCUS_SELECTORS.note && focusSelector(document, ".weave-pane-active .weave-note-editor")) return;
275
+ if (!focusSelector(document, selector) && selector === COLUMN_FOCUS_SELECTORS.note) focusSelector(document, ".weave-pane-active .weave-pane-content");
276
+ });
277
+ return true;
278
+ },
279
+ }),
213
280
  }), []);
281
+ useLayoutEffect(() => {
282
+ for (const group of layout.panes) {
283
+ document.getElementById(`tab-${group.activeTab}`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
284
+ }
285
+ }, [layout.panes.map((group) => group.activeTab).join(","), layout.activePane, width, ready, data.graph?.stamp,
286
+ layout.treeVisible, layout.treeWidth, layout.contextVisible, layout.contextWidth, layout.ratio, layout.split]);
287
+ useLayoutEffect(() => {
288
+ const slot = graphSlot.current;
289
+ const parent = root.current;
290
+ if (!graphVisible || !slot || !parent) return;
291
+ const measure = (): void => {
292
+ const at = slot.getBoundingClientRect(); const base = parent.getBoundingClientRect();
293
+ setGraphBox({ left: at.left - base.left, top: at.top - base.top, width: at.width, height: at.height });
294
+ };
295
+ measure();
296
+ const observer = new ResizeObserver(measure); observer.observe(slot); observer.observe(parent);
297
+ return () => observer.disconnect();
298
+ }, [graphVisible, layout, width]);
214
299
 
215
- return (
216
- <>
217
- <Header
218
- summary={summarize(workspaceState.graph)}
219
- shortcut={searchShortcut(looksApple(props.platform))}
220
- onRefresh={() => workspace.current?.refresh()}
221
- onSearch={() => setOverlay("search")}
222
- theme={themeButton(theme)}
223
- onTheme={() => setTheme((current) => cycleTheme(current))}
224
- />
225
- <Columns
226
- resolved={resolved}
227
- onDown={drag.onDown}
228
- onMove={drag.onMove}
229
- onUp={drag.onUp}
230
- onKey={drag.onKey}
231
- scheme={effectiveScheme(theme, systemScheme)}
232
- bootFailed={workspaceState.graphFailed}
233
- graph={workspaceState.graph}
234
- note={workspaceState.note}
235
- noteFailed={workspaceState.noteFailed}
236
- selectedId={workspaceState.selectedId}
237
- recentIds={workspaceState.recentIds}
238
- onSelect={select}
239
- onMutate={mayDiscard}
240
- onRefresh={() => workspace.current?.refresh()}
241
- onOpen={(slug) => void (async () => {
242
- // Reported, not discarded: a 403 and an editor that opened nothing
243
- // used to look identical. `alert` is what the tree already uses.
244
- const result = await openNote(fetchJson, slug);
245
- if (!result.ok) window.alert(result.message);
246
- else if (!result.data.opened) window.alert("could not open the note in an editor");
247
- })()}
248
- onSave={async (slug, body) => {
249
- const result = await saveNote(fetchJson, slug, body);
250
- if (!result.ok) {
251
- window.alert(result.message);
252
- return false;
253
- }
254
- // The poll would get there in two seconds; this agrees immediately.
255
- workspace.current?.refresh();
256
- return true;
257
- }}
258
- editor={editor}
259
- now={now}
260
- // The graph column's three ports (§7.5, §10). Supplied here, at the
261
- // one place that is already allowed to name browser globals, so
262
- // `Graph.tsx` and everything under it takes its world as parameters.
263
- renderer={createSigmaRenderer}
264
- storage={localStorage}
265
- host={window}
266
- fit={fit}
267
- tuner={props.tuner === true}
268
- />
269
- {/*
270
- `model.generatedAt`, not `stamp`. The two used to be the same string;
271
- since §15.6 `stamp` is the ETag's content digest and would render as
272
- `a3f9c2…` under a label that
273
- says "data as of". `generatedAt` kept the data-as-of job, which is
274
- exactly what this bar wants.
275
- */}
276
- <StatusBar
277
- model={statusBarModel(props.cwd, workspaceState.selectedId, workspaceState.graph?.model.generatedAt ?? null)}
278
- />
279
- {overlay === "search" ? (
280
- <SearchPalette
281
- graph={workspaceState.graph}
282
- onSelect={select}
283
- onClose={() => setOverlay(null)}
284
- ports={{ fetch: fetchJson }}
285
- />
286
- ) : null}
287
- {overlay === "help" ? (
288
- <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} />
289
- ) : null}
290
- </>
291
- );
300
+ const rememberScroll = (tabId: string, scroll: number): void => setLayout((current) => ({ ...current,
301
+ panes: current.panes.map((group) => ({ ...group, tabs: group.tabs.map((entry) => entry.id === tabId ? { ...entry, scroll } : entry) })),
302
+ }));
303
+ const tabDragOver = (paneId: string, event: DragEvent): void => {
304
+ if (tabDropSource(layout, paneId, draggedTab.current) === null && !event.dataTransfer?.types.includes(NOTE_DRAG_TYPE)) return;
305
+ event.preventDefault();
306
+ if (event.dataTransfer) event.dataTransfer.dropEffect = draggedTab.current === null ? "copy" : "move";
307
+ setDropPane(paneId);
308
+ };
309
+ const tabDrop = (paneId: string, event: DragEvent): void => {
310
+ const noteId = noteDropId(data.graph, event.dataTransfer?.getData(NOTE_DRAG_TYPE) ?? "");
311
+ if (noteId !== null) { event.preventDefault(); select(noteId, true, paneId); setDropPane(null); return; }
312
+ const id = draggedTab.current;
313
+ if (tabDropSource(layout, paneId, id) === null) return;
314
+ event.preventDefault();
315
+ setLayout((value) => {
316
+ const source = tabDropSource(value, paneId, id);
317
+ return source === null ? value : moveTab(value, source, id!);
318
+ });
319
+ draggedTab.current = null;
320
+ setDropPane(null);
321
+ };
322
+ const renderPane = (group: Pane) => {
323
+ const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
324
+ const isActive = group.id === layout.activePane;
325
+ return <section key={group.id} class={`weave-pane${isActive ? " weave-pane-active" : ""}${dropPane === group.id ? " weave-pane-drop" : ""}`} data-drop-label={draggedTab.current === null ? "Open note here" : "Move tab here"} aria-label={`Workspace pane ${layout.panes.indexOf(group) + 1}`}
326
+ onDragOver={(event) => tabDragOver(group.id, event)} onDrop={(event) => tabDrop(group.id, event)}
327
+ onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
328
+ hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
329
+ onPointerDown={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}>
330
+ <div class="weave-tabs" role="tablist" aria-label="Open documents">
331
+ {group.tabs.map((entry) => <div key={entry.id} class={`weave-tab${entry.id === current.id ? " weave-tab-active" : ""}`}>
332
+ <button type="button" role="tab" title={titleOf(entry, data.graph)} id={`tab-${entry.id}`} aria-selected={entry.id === current.id} aria-controls={`panel-${group.id}`} tabIndex={entry.id === current.id ? 0 : -1}
333
+ draggable={layout.panes.length === 2}
334
+ onDragStart={(event) => { draggedTab.current = entry.id; event.dataTransfer!.effectAllowed = "move"; event.dataTransfer!.setData("application/x-weave-tab", entry.id); }}
335
+ onClick={() => setLayout(activateTab(layout, group.id, entry.id))}
336
+ onKeyDown={(event) => {
337
+ const offset = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
338
+ if (!offset && event.key !== "Home" && event.key !== "End") return;
339
+ event.preventDefault();
340
+ const index = event.key === "Home" ? 0 : event.key === "End" ? group.tabs.length - 1 : (group.tabs.indexOf(entry) + offset + group.tabs.length) % group.tabs.length;
341
+ const next = group.tabs[index]!; setLayout(activateTab(layout, group.id, next.id));
342
+ requestAnimationFrame(() => document.getElementById(`tab-${next.id}`)?.focus());
343
+ }}><span aria-hidden="true">{entry.kind === "graph" ? "◌" : "▤"}</span><span>{titleOf(entry, data.graph)}</span>
344
+ {drafts.isDirty((tabSelection(entry) ?? "").slice(5)) ? <span class="weave-dirty" aria-label="Unsaved changes">●</span> : null}</button>
345
+ <button type="button" class="weave-tab-close" aria-label={`Close ${titleOf(entry, data.graph)}`} onClick={() => change(closeTab(layout, group.id, entry.id))}>×</button>
346
+ </div>)}
347
+ <button type="button" class="weave-new-tab" aria-label="New tab" onClick={() => select(null, true, group.id)}>+</button>
348
+ </div>
349
+ <div class="weave-pane-toolbar">
350
+ <button type="button" aria-label="Navigate back" disabled={current.kind === "graph" || current.cursor === 0} onClick={() => change(navigateTab({ ...layout, activePane: group.id }, group.id, -1))}>←</button>
351
+ <button type="button" aria-label="Navigate forward" disabled={current.kind === "graph" || current.cursor >= current.history.length - 1} onClick={() => change(navigateTab({ ...layout, activePane: group.id }, group.id, 1))}>→</button>
352
+ <span class="weave-breadcrumb">{titleOf(current, data.graph)}</span>
353
+ <details class="weave-pane-menu" onKeyDown={(event) => {
354
+ if (event.key !== "Escape") return;
355
+ event.preventDefault(); event.stopPropagation(); event.currentTarget.open = false;
356
+ event.currentTarget.querySelector("summary")?.focus();
357
+ }}><summary aria-label="Pane options" title="Pane options">···</summary><div onClick={(event) => { const details = event.currentTarget.closest("details"); if (details) details.open = false; }}>
358
+ <button type="button" onClick={() => setLayout(splitPane({ ...layout, activePane: group.id }, "right"))}>{layout.panes.length > 1 ? "Arrange side by side" : "Split right"}</button>
359
+ <button type="button" onClick={() => setLayout(splitPane({ ...layout, activePane: group.id }, "down"))}>{layout.panes.length > 1 ? "Stack panes" : "Split down"}</button>
360
+ {layout.panes.length > 1 ? <><button type="button" onClick={() => setLayout(moveTab(layout, group.id, current.id))}>Move tab to other pane</button><button type="button" title="Move all tabs to the other pane and close this pane" onClick={() => setLayout(closePane(layout, group.id))}>Close pane</button></> : null}
361
+ </div></details>
362
+ </div>
363
+ <div class="weave-pane-content" role="tabpanel" tabIndex={-1} id={`panel-${group.id}`} aria-labelledby={`tab-${current.id}`} {...(current.kind === "graph" ? { ref: graphSlot } : {})}>
364
+ {current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
365
+ onSelect={(id, newTab) => select(id, newTab, group.id)} onSave={save} onScroll={(value) => rememberScroll(current.id, value)}
366
+ onSearch={() => setOverlay("search")} onGraph={showGraph} /> : null}
367
+ </div>
368
+ </section>;
369
+ };
370
+
371
+ return <>
372
+ <div class="weave-workbench" ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); }}>
373
+ <nav class="weave-ribbon" aria-label="Workspace tools">
374
+ <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
375
+ <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
376
+ <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}>
377
+ <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>
378
+ </button>
379
+ <span class="weave-ribbon-space" />
380
+ <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}>☷</button>
381
+ <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}>↻</button>
382
+ <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: cycleTheme(layout.theme) })}>{theme.glyph}</button>
383
+ <button type="button" aria-label="Keyboard shortcuts" onClick={() => setOverlay("help")}>?</button>
384
+ </nav>
385
+ <div class="weave-sidebar-dock weave-notes-dock" data-open={layout.treeVisible} inert={!layout.treeVisible} aria-hidden={!layout.treeVisible} style={{ "--weave-sidebar-width": `${Math.min(layout.treeWidth, width - 90)}px` }}><aside class="weave-sidebar weave-sidebar-notes" aria-label="Notes sidebar">
386
+ <div class="weave-sidebar-heading"><button type="button" aria-pressed={!recentMode} onClick={() => setRecentMode(false)}>Files</button><button type="button" aria-pressed={recentMode} onClick={() => setRecentMode(true)}>Recent</button><button type="button" aria-label="Hide notes sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle notes sidebar"]')?.focus(); setLayout({ ...layout, treeVisible: false }); }}>«</button></div>
387
+ {recentMode ? <RecentList visits={visits} graph={data.graph} selectedId={selectedId} onSelect={select} /> : <Tree graph={data.graph} selectedId={selectedId} recentIds={data.recentIds} onSelect={(id, newTab, keepSidebar) => select(id, newTab, layout.activePane, keepSidebar)} onMutate={mayMutate} onRefresh={() => workspace.current?.refresh()} now={now} />}
388
+ <div class="weave-vault-label"><span>Workspace</span><strong title={props.cwd}>{props.cwd.split(/[\\/]/).filter(Boolean).pop() ?? "Weave"}</strong></div>
389
+ </aside><ResizeHandle label="Resize notes sidebar" min={180} max={420} value={layout.treeWidth} onChange={(delta) => setLayout((value) => ({ ...value, treeWidth: Math.max(180, Math.min(420, value.treeWidth + delta)) }))} /></div>
390
+ <main class={`weave-panes weave-split-${layout.split}`} style={layout.panes.length === 2 && width >= 850 ? { [layout.split === "right" ? "gridTemplateColumns" : "gridTemplateRows"]: `minmax(0, ${layout.ratio}fr) 4px minmax(0, ${1 - layout.ratio}fr)` } : {}}>
391
+ {!ready ? <div class="weave-welcome" role="status">Opening workspace…</div> : layout.panes.map((group, index) => <>{index > 0 && width >= 850 ? <ResizeHandle label="Resize split panes" min={20} max={80} horizontal={layout.split === "down"} value={layout.ratio * 100} onChange={(delta) => {
392
+ const extent = layout.split === "right" ? root.current?.querySelector("main")?.clientWidth : root.current?.querySelector("main")?.clientHeight;
393
+ setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
394
+ }} /> : null}{renderPane(group)}</>)}
395
+ </main>
396
+ <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>
397
+ {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" }}>
398
+ <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={effectiveScheme(layout.theme, scheme)} bootFailed={data.graphFailed} fit={fit} tuner={props.tuner === true} />
399
+ </div> : null}
400
+ </div>
401
+ <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId, data.graph?.model.generatedAt ?? null)} />
402
+ {persistError ? <span role="status">Workspace restoration unavailable</span> : null}
403
+ {drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
404
+ {width < 850 && layout.panes.length > 1 ? <button type="button" onClick={() => setLayout({ ...layout, activePane: layout.panes.find((group) => group.id !== pane.id)!.id })}>Switch pane</button> : null}
405
+ </div>
406
+ {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
407
+ {overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
408
+ </>;
292
409
  }