pi-weave 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -16
- package/package.json +1 -2
- package/src/core/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/pi/viewer/tui/surface/detail.ts +1 -1
- package/src/web/client/dist/app.js +210 -178
- package/src/web/client/graph/Graph.tsx +18 -0
- package/src/web/client/graph/column.model.ts +19 -2
- package/src/web/client/graph/graph.model.ts +1 -1
- package/src/web/client/graph/positions.ts +1 -1
- package/src/web/client/graph/renderer.ts +9 -1
- package/src/web/client/note/Note.tsx +66 -91
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Shell.tsx +371 -254
- package/src/web/client/shell/StatusBar.tsx +1 -1
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +26 -31
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +31 -107
- package/src/web/client/shell/theme.model.ts +4 -4
- package/src/web/client/shell/theme.ts +12 -93
- package/src/web/client/shell/workspace.css.ts +117 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/routes.ts +34 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/workspace.ts +270 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- package/src/web/client/shell/layout.model.ts +0 -132
|
@@ -1,292 +1,409 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
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 {
|
|
16
|
+
import { Icon, Tree } from "../tree/Tree";
|
|
29
17
|
import { initialWorkspaceState } from "../state";
|
|
30
|
-
import
|
|
18
|
+
import { startWorkspace, watchNote } from "../workspace";
|
|
31
19
|
import type { WorkspaceHandle } from "../workspace";
|
|
32
|
-
import {
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 [
|
|
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 [
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const [
|
|
82
|
-
const [
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
workspace.current
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (!
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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("
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
() =>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
}
|