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.
Files changed (40) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -2
  3. package/src/core/frontmatter.ts +3 -3
  4. package/src/core/graph/model.ts +2 -2
  5. package/src/core/graph/wikilinks.ts +1 -1
  6. package/src/core/types.ts +1 -1
  7. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  8. package/src/web/client/dist/app.js +210 -178
  9. package/src/web/client/graph/Graph.tsx +18 -0
  10. package/src/web/client/graph/column.model.ts +19 -2
  11. package/src/web/client/graph/graph.model.ts +1 -1
  12. package/src/web/client/graph/positions.ts +1 -1
  13. package/src/web/client/graph/renderer.ts +9 -1
  14. package/src/web/client/note/Note.tsx +66 -91
  15. package/src/web/client/note/drafts.ts +172 -0
  16. package/src/web/client/note/note.model.ts +2 -34
  17. package/src/web/client/search/SearchPalette.tsx +9 -8
  18. package/src/web/client/shell/ContextRail.tsx +8 -9
  19. package/src/web/client/shell/Shell.tsx +371 -254
  20. package/src/web/client/shell/StatusBar.tsx +1 -1
  21. package/src/web/client/shell/focus.model.ts +2 -2
  22. package/src/web/client/shell/keys.model.ts +26 -31
  23. package/src/web/client/shell/keys.ts +1 -0
  24. package/src/web/client/shell/shell.model.ts +31 -107
  25. package/src/web/client/shell/theme.model.ts +4 -4
  26. package/src/web/client/shell/theme.ts +12 -93
  27. package/src/web/client/shell/workspace.css.ts +117 -0
  28. package/src/web/client/tree/Tree.tsx +43 -14
  29. package/src/web/client/tree/tree.model.ts +19 -0
  30. package/src/web/client/workspace.ts +19 -1
  31. package/src/web/server/routes.ts +34 -3
  32. package/src/web/server/server.ts +5 -0
  33. package/src/web/server/workspace-state.ts +121 -0
  34. package/src/web/shared/workspace.ts +270 -0
  35. package/src/web/client/selection.storage.ts +0 -69
  36. package/src/web/client/shell/Columns.tsx +0 -139
  37. package/src/web/client/shell/Divider.tsx +0 -15
  38. package/src/web/client/shell/Header.tsx +0 -119
  39. package/src/web/client/shell/drag.model.ts +0 -35
  40. package/src/web/client/shell/layout.model.ts +0 -132
@@ -42,6 +42,7 @@ import {
42
42
  graphClick,
43
43
  graphColumnModel,
44
44
  graphCountLabel,
45
+ graphPreview,
45
46
  hoverHighlight,
46
47
  } from "./column.model";
47
48
  import type { PositionStorage } from "./positions";
@@ -54,11 +55,14 @@ import type { GraphSimulation } from "./dynamics";
54
55
  import { ForceTuner } from "./ForceTuner";
55
56
  import { POSITIONS_STORAGE_KEY } from "./positions";
56
57
  import { loadGroupColors, saveGroupColors } from "./tuner.model";
58
+ import { Icon } from "../tree/Tree";
57
59
 
58
60
  export interface GraphProps {
59
61
  graph: GraphPayload | null;
60
62
  selectedId: string | null;
61
63
  onSelect: (id: string | null) => void;
64
+ previewId: string | null;
65
+ onOpen: (id: string) => void;
62
66
  /** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
63
67
  renderer: RendererFactory;
64
68
  /** Injected: `localStorage`. See `positions.ts` for why it is a port. */
@@ -170,6 +174,7 @@ export function Graph(props: GraphProps) {
170
174
  () => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
171
175
  [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceRev, groupColors],
172
176
  );
177
+ const preview = graphPreview(props.graph, props.previewId);
173
178
 
174
179
  // Read by the mount-time `onSelect`, which outlives this render.
175
180
  const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
@@ -276,6 +281,19 @@ export function Graph(props: GraphProps) {
276
281
  {/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
277
282
  comment. The tree's target is the rows `<ul>`, which has its own. */}
278
283
  <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
284
+ {preview === null ? null : (
285
+ <aside class="weave-graph-preview" aria-label="Selected graph node">
286
+ <div class="weave-graph-preview-info">
287
+ <span class="weave-kind" aria-hidden="true"><Icon name={preview.icon} class="weave-icon" /></span>
288
+ <div class="weave-graph-preview-copy">
289
+ <span class="weave-graph-preview-kind">{preview.kind}</span>
290
+ <strong class="weave-graph-preview-title" title={preview.title}>{preview.title}</strong>
291
+ </div>
292
+ </div>
293
+ <button type="button" class="weave-graph-preview-close" aria-label="Clear graph selection" onClick={() => props.onSelect(null)}>×</button>
294
+ <button type="button" class="weave-chip" onClick={() => props.onOpen(preview.id)}>Open in new tab</button>
295
+ </aside>
296
+ )}
279
297
  {tunerOpen ? (
280
298
  <ForceTuner
281
299
  groupColors={groupColors}
@@ -43,7 +43,8 @@ import type { Point } from "../../shared/layout";
43
43
  import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
44
44
  import { clusterAggregate, focusNeighborhood } from "../../shared/view";
45
45
  import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
46
- import { viewModel } from "../tree/tree.model";
46
+ import { kindIcon, viewModel } from "../tree/tree.model";
47
+ import type { IconName } from "../shell/icons.model";
47
48
  import type { ColorScheme, RenderGraph } from "./graph.model";
48
49
  import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
49
50
  import { groupNodeColors } from "./groups";
@@ -82,6 +83,22 @@ export interface GraphViewState {
82
83
  readonly expanded: ReadonlySet<string>;
83
84
  }
84
85
 
86
+ export interface GraphPreview {
87
+ readonly id: string;
88
+ readonly title: string;
89
+ readonly kind: string;
90
+ readonly icon: IconName;
91
+ }
92
+
93
+ /** The original graph node selected for preview, including collapsed folders. */
94
+ export function graphPreview(payload: GraphPayload | null, id: string | null): GraphPreview | null {
95
+ if (payload === null || id === null) return null;
96
+ const node = payload.model.nodes.find((candidate) => candidate.id === id);
97
+ if (node === undefined) return null;
98
+ const kind = id.startsWith("artifact:") ? "HTML artifact" : id.startsWith("vfolder:") ? "Folder" : node.kind;
99
+ return { id, title: node.label, kind, icon: kindIcon(node.kind) };
100
+ }
101
+
85
102
  /**
86
103
  * The state a freshly loaded graph opens in: **fully expanded**.
87
104
  *
@@ -169,7 +186,7 @@ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string
169
186
  * The highlight while the pointer is over a node — **hover wins, selection is
170
187
  * what it falls back to**.
171
188
  *
172
- * Obsidian's gesture, and the reason it is the same function as
189
+ * The reference interaction, and the reason it is the same function as
173
190
  * {@link highlightFor} rather than a second visual language: hovering asks
174
191
  * exactly the question clicking asks ("what is one hop from here?"), so the
175
192
  * two must produce the same set and reach the same reducers. Anything else
@@ -443,7 +443,7 @@ export const HOVER_LABEL_SHADOW = 6;
443
443
  /**
444
444
  * Sigma's `defaultDrawNodeHover`, as a scheme-bound pure function (§7.4).
445
445
  *
446
- * Obsidian's gesture, which is the reference: the hovered node's name floats
446
+ * The reference gesture: the hovered node's name floats
447
447
  * **centred underneath it**, in the theme's own text colour, with no
448
448
  * container. The alternative shipped by sigma is a white rounded box with the
449
449
  * text to the node's *right* — wrong colour in both schemes, and wrong place
@@ -175,7 +175,7 @@ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly W
175
175
  * watcher fires, the graph refetches, and a picture the user was reading
176
176
  * rearranges itself for no visible reason. Never re-running leaves new nodes
177
177
  * unplaced and therefore undrawn (`renderGraph` drops a node with no
178
- * position), so a note created in Obsidian would never appear.
178
+ * position), so a note created outside this application would never appear.
179
179
  *
180
180
  * The shape key is exactly the line between those: it changes iff a node or an
181
181
  * edge was added or removed, which is precisely when the existing positions
@@ -44,6 +44,7 @@ export type RendererFactory = (scheme: ColorScheme) => GraphRenderer;
44
44
  */
45
45
  export interface SigmaLike {
46
46
  on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
47
+ on(event: "doubleClickNode", handler: (payload: { node: string; preventSigmaDefault(): void }) => void): unknown;
47
48
  on(event: "clickStage", handler: () => void): unknown;
48
49
  on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
49
50
  on(event: "enterNode", handler: (payload: { node: string }) => void): unknown;
@@ -146,6 +147,7 @@ export function sigmaRenderer(
146
147
  let dragMove: (id: string, at: Point) => void = () => {};
147
148
  let dragEnd: (id: string) => void = () => {};
148
149
  let dragging: string | null = null;
150
+ let nodeMoved = false;
149
151
 
150
152
  const endDrag = (id: string | null): void => {
151
153
  if (dragging === null) return;
@@ -208,16 +210,22 @@ export function sigmaRenderer(
208
210
  const instance = create(graph, container, graphSettings(scheme));
209
211
  // §1.3's context bus: a click writes `selectedId`, and the note column,
210
212
  // the tree and the context rail all recompute from it.
211
- instance.on("clickNode", ({ node }) => select(node));
213
+ instance.on("clickNode", ({ node }) => { if (!nodeMoved) select(node); });
214
+ instance.on("doubleClickNode", ({ node, preventSigmaDefault }) => {
215
+ preventSigmaDefault();
216
+ if (!nodeMoved) select(node);
217
+ });
212
218
  instance.on("clickStage", () => select(null));
213
219
  instance.on("downNode", ({ node }) => {
214
220
  dragging = node;
221
+ nodeMoved = false;
215
222
  // Hold the view still while the node follows the cursor (see endDrag).
216
223
  sigma?.setSetting("enableCameraPanning", false);
217
224
  dragStart(node);
218
225
  });
219
226
  instance.on("moveBody", (payload) => {
220
227
  if (dragging !== null) {
228
+ nodeMoved = true;
221
229
  // Sigma's captor pans the camera on every mouse move while the
222
230
  // button is down; during a node drag that is the pan the gesture
223
231
  // must not be. `preventSigmaDefault` is the captor's own gate — it
@@ -11,8 +11,8 @@
11
11
  * The explicit Edit control swaps the body for a `<textarea>`; `draft` is
12
12
  * `null` in read mode, so text and mode cannot disagree. No reducer — the 686-line one that
13
13
  * stood here resolved conflicts against a revision the server no longer
14
- * issues. Being component state, the draft is out of the 2 s poll's reach;
15
- * the shell guards it through the `editor` slot below.
14
+ * issues. Drafts are keyed by note identity in the shared in-memory store, out of the
15
+ * 2 s poll's reach; the shell guards removal of their final visible tab.
16
16
  *
17
17
  * `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
18
18
  * alternative is parsing marked's output into a Preact tree, which means a
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  import DOMPurify from "dompurify";
42
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
42
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
43
43
  import type { GraphPayload, NotePayload } from "../../shared/wire";
44
44
  import {
45
45
  CREATED_WORD,
@@ -50,15 +50,11 @@ import {
50
50
  EDITED_WORD,
51
51
  EDITOR_ARIA_LABEL,
52
52
  EMPTY_PREVIEW,
53
- PREVIEW_ID,
54
53
  SAVE_LABEL,
55
54
  SAVING_LABEL,
56
55
  TASK_CHECKBOX_ATTR,
57
56
  WIKILINK_ATTR,
58
- draftDirty,
59
- draftMoved,
60
57
  hasTextSelection,
61
- saveLanded,
62
58
  noteEmptyMessage,
63
59
  noteHeader,
64
60
  previewAnchorOf,
@@ -75,6 +71,8 @@ import {
75
71
  wikilinkTargetOf,
76
72
  } from "./note.model";
77
73
  import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
74
+ import { createDraftStore } from "./drafts";
75
+ import type { DraftStore } from "./drafts";
78
76
 
79
77
  /**
80
78
  * The custom properties the preview card is placed with, written by the
@@ -90,7 +88,7 @@ export interface NoteProps {
90
88
  loadFailed: boolean;
91
89
  graph: GraphPayload | null;
92
90
  selectedId: string | null;
93
- onSelect: (id: string) => void;
91
+ onSelect: (id: string, newTab?: boolean) => void;
94
92
  onOpen: (slug: string) => void;
95
93
  /**
96
94
  * Save the note's body. Resolves `true` when the write landed; a `false`
@@ -98,12 +96,12 @@ export interface NoteProps {
98
96
  * error-reporting gesture the client has.
99
97
  */
100
98
  onSave: (slug: string, body: string) => Promise<boolean>;
101
- /**
102
- * The shell's handle on the open editor — the `fit` pattern again. Guards
103
- * both *ask* (navigate, unload) and *close* (a tree rename of this note).
104
- * Functions, not values, or a mount-time guard answers "clean" forever.
105
- */
106
- editor: { current: { dirty(): boolean; discard(): void } | null };
99
+ /** Shared across Note instances so duplicate tabs edit one in-memory body. */
100
+ drafts?: DraftStore;
101
+ /** Stable per view; distinct when the same note is mounted more than once. */
102
+ idPrefix?: string;
103
+ /** Monotonic identity of the document load that produced `note`. */
104
+ loadVersion: number;
107
105
  /** Epoch ms for relative times. Injected so the render is deterministic. */
108
106
  now: number;
109
107
  }
@@ -121,6 +119,7 @@ function Header({
121
119
  open,
122
120
  editing,
123
121
  saving,
122
+ taskSaving,
124
123
  onToggle,
125
124
  onSave,
126
125
  }: {
@@ -128,6 +127,7 @@ function Header({
128
127
  open: () => void;
129
128
  editing: boolean;
130
129
  saving: boolean;
130
+ taskSaving: boolean;
131
131
  onToggle: () => void;
132
132
  onSave: () => void;
133
133
  }) {
@@ -137,12 +137,12 @@ function Header({
137
137
  <p class="weave-note-meta">
138
138
  {editing ? (
139
139
  <>
140
- <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
140
+ <button type="button" class="weave-note-save" disabled={saving || taskSaving} onClick={onSave}>
141
141
  {saving ? SAVING_LABEL : SAVE_LABEL}
142
142
  </button>
143
- <button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
143
+ <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
144
144
  </>
145
- ) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
145
+ ) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
146
146
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
147
147
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
148
148
  </button>
@@ -168,7 +168,14 @@ function Header({
168
168
  }
169
169
 
170
170
  export function Note(props: NoteProps) {
171
+ const localDrafts = useRef(createDraftStore());
172
+ const drafts = props.drafts ?? localDrafts.current;
173
+ const generatedId = useId();
174
+ const previewId = `${props.idPrefix ?? generatedId}-weave-preview`;
175
+ const [storeVersion, setDraftVersion] = useState(0);
176
+ useEffect(() => drafts.subscribe(() => setDraftVersion((version) => version + 1)), [drafts]);
171
177
  const note = props.note?.note ?? null;
178
+ const body = note === null ? "" : drafts.getTaskBody(note.slug, note.body);
172
179
  const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
173
180
  const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
174
181
 
@@ -180,8 +187,8 @@ export function Note(props: NoteProps) {
180
187
  // only the *result* is memoized.
181
188
  const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
182
189
  const html = useMemo(
183
- () => (note === null || index === null ? "" : renderNote(DOMPurify, note.body, index)),
184
- [note, index],
190
+ () => (note === null || index === null ? "" : renderNote(DOMPurify, body, index)),
191
+ [note, body, index],
185
192
  );
186
193
 
187
194
  // The hover card. The reducer lives in `note.model.ts`; this is its
@@ -192,90 +199,51 @@ export function Note(props: NoteProps) {
192
199
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
193
200
  const bodyRef = useRef<HTMLDivElement | null>(null);
194
201
  const cardRef = useRef<HTMLDivElement | null>(null);
195
- const taskSaving = useRef(false);
196
- const taskSource = useRef<{ slug: string; serverBody: string; body: string } | null>(null);
197
- if (note === null) taskSource.current = null;
198
- else if (taskSource.current === null || taskSource.current.slug !== note.slug || taskSource.current.serverBody !== note.body) {
199
- taskSource.current = { slug: note.slug, serverBody: note.body, body: note.body };
200
- }
201
-
202
- const [draft, setDraft] = useState<string | null>(null);
203
- const [saving, setSaving] = useState(false);
204
- const dirty = note !== null && draft !== null && draftDirty(draft, note.body);
205
- // Bumped on open and close, so a late reply knows it is late (`saveLanded`).
206
- const session = useRef(0);
207
- // The draft *now*: `save` outlives the render that captured its own copy.
208
- const draftRef = useRef(draft);
209
- draftRef.current = draft;
210
-
211
- // During render, not in an effect: an effect runs after paint, leaving a
212
- // window where the slot is still clean and a click slips the guard.
213
- props.editor.current = { dirty: () => dirty, discard: () => close() };
214
- useEffect(() => () => {
215
- props.editor.current = null;
216
- }, [props.editor]);
217
-
218
- const close = (): void => {
219
- session.current += 1;
220
- setDraft(null);
221
- setSaving(false);
222
- };
223
-
224
- const open = (body: string): void => {
225
- session.current += 1;
226
- setDraft(body);
227
- };
228
-
202
+ const draft = note === null ? null : drafts.get(note.slug);
203
+ const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
229
204
  const save = async (): Promise<void> => {
230
- if (note === null || draft === null || saving) return;
231
- const issued = session.current;
232
- // The textarea stays enabled through the request, so the draft can move
233
- // underneath it; the reply is only ever about these bytes.
234
- const sent = draft;
235
- setSaving(true);
236
- const ok = await props.onSave(note.slug, sent);
237
- // A reply for an editor that is gone must not close the one now open.
238
- if (!saveLanded(issued, session.current)) return;
239
- // Stay open on failure, and on success the user typed through: both hold
240
- // text the server does not have, and closing would discard it.
241
- if (!ok || draftMoved(sent, draftRef.current)) setSaving(false);
242
- else close();
205
+ if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
243
206
  };
244
207
 
245
208
  const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
246
- const source = taskSource.current;
247
209
  const restore = (): void => {
248
210
  checkbox.checked = !checkbox.checked;
249
211
  checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
250
212
  };
251
- if (source === null || taskSaving.current) {
213
+ if (note === null || drafts.isTaskSaving(note.slug)) {
252
214
  restore();
253
215
  return;
254
216
  }
255
- const body = toggleTaskCheckbox(source.body, taskIndex);
256
- if (body === null) {
217
+ const nextBody = toggleTaskCheckbox(drafts.getTaskBody(note.slug, note.body), taskIndex);
218
+ if (nextBody === null) {
257
219
  restore();
258
220
  return;
259
221
  }
260
222
 
261
223
  const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
262
- taskSaving.current = true;
263
224
  checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
264
225
  for (const input of inputs) input.disabled = true;
265
- const ok = await props.onSave(source.slug, body);
266
- if (ok) source.body = body;
267
- else restore();
268
- for (const input of inputs) input.disabled = false;
269
- taskSaving.current = false;
226
+ try {
227
+ if (!await drafts.saveTask(note.slug, nextBody, props.onSave)) restore();
228
+ } catch (error) {
229
+ restore();
230
+ throw error;
231
+ } finally {
232
+ for (const input of inputs) input.disabled = drafts.isTaskSaving(note.slug);
233
+ }
270
234
  };
271
235
 
272
236
  const toggleEdit = (): void => {
273
- if (note === null) return;
274
- if (draft === null) open(note.body);
275
- else if (!dirty || window.confirm(DISCARD_PROMPT)) close();
237
+ if (note === null || drafts.isTaskSaving(note.slug)) return;
238
+ if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
239
+ else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
276
240
  };
277
241
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
278
242
 
243
+ useLayoutEffect(() => {
244
+ if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
245
+ }, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
246
+
279
247
  // A card whose target note left the screen is a claim about a document that
280
248
  // is gone. Navigating (or an SSE swap of the open note, which is what
281
249
  // `note.slug` changing underneath the pointer looks like) closes it rather
@@ -284,12 +252,18 @@ export function Note(props: NoteProps) {
284
252
  // slug changes as rarely as navigation happens.
285
253
  useLayoutEffect(() => {
286
254
  dispatch({ type: "hide" });
287
- // A different note is a different document: an open editor holding the
288
- // previous one's text would save it over this one. The shell's guard is
289
- // what asks before a dirty draft gets here.
290
- close();
255
+ // Drafts belong to note identity and survive this Note instance changing
256
+ // documents or unmounting.
291
257
  }, [note === null ? null : note.slug]);
292
258
 
259
+ useLayoutEffect(() => {
260
+ if (note === null) return;
261
+ const saving = drafts.isTaskSaving(note.slug);
262
+ for (const input of bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? []) {
263
+ input.disabled = saving;
264
+ }
265
+ }, [note === null ? null : note.slug, storeVersion, html]);
266
+
293
267
  // Wired after the card mounts, because both jobs depend on the live DOM:
294
268
  // the card's size (the placement decision needs measured dimensions, which
295
269
  // no render can know), and `aria-describedby`, which the link has to carry
@@ -302,7 +276,7 @@ export function Note(props: NoteProps) {
302
276
  if (body !== null) {
303
277
  const slug = preview.anchor?.slug ?? null;
304
278
  for (const link of body.querySelectorAll(`a[${WIKILINK_ATTR}]`)) {
305
- if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby", PREVIEW_ID);
279
+ if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby", previewId);
306
280
  else link.removeAttribute("aria-describedby");
307
281
  }
308
282
  }
@@ -322,7 +296,7 @@ export function Note(props: NoteProps) {
322
296
  // `setProperty` is the path it has no hook on.
323
297
  element.style.setProperty(`--${PREVIEW_X}`, `${spot.x}px`);
324
298
  element.style.setProperty(`--${PREVIEW_Y}`, `${spot.y}px`);
325
- }, [preview, card]);
299
+ }, [preview, card, previewId]);
326
300
 
327
301
  if (artifactPath !== null) {
328
302
  const artifact = props.graph?.model.nodes.find((node) => node.id === props.selectedId);
@@ -360,7 +334,8 @@ export function Note(props: NoteProps) {
360
334
  view={header}
361
335
  open={() => props.onOpen(note.slug)}
362
336
  editing={draft !== null}
363
- saving={saving}
337
+ saving={draft?.saving ?? false}
338
+ taskSaving={taskSaving}
364
339
  onToggle={toggleEdit}
365
340
  onSave={() => void save()}
366
341
  />
@@ -369,8 +344,8 @@ export function Note(props: NoteProps) {
369
344
  class="weave-note-editor"
370
345
  aria-label={EDITOR_ARIA_LABEL}
371
346
  spellcheck
372
- value={draft}
373
- onInput={(event) => setDraft((event.target as HTMLTextAreaElement).value)}
347
+ value={draft.body}
348
+ onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
374
349
  onKeyDown={(event) => {
375
350
  // Bound here, not in `keys.model.ts`: a global binding is a
376
351
  // workspace-wide claim, and Escape must not reach the shell.
@@ -429,7 +404,7 @@ export function Note(props: NoteProps) {
429
404
  if (hasTextSelection(selection)) return;
430
405
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
431
406
  // A wikilink carries no href, so route it onto the context bus.
432
- if (target !== null) props.onSelect(target);
407
+ if (target !== null) props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
433
408
  }}
434
409
  onKeyDown={(event) => {
435
410
  // Escape is first so the card closes on the gesture a keyboard
@@ -446,7 +421,7 @@ export function Note(props: NoteProps) {
446
421
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
447
422
  if (target === null) return;
448
423
  event.preventDefault();
449
- props.onSelect(target);
424
+ props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
450
425
  }}
451
426
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
452
427
  dangerouslySetInnerHTML={{ __html: html }}
@@ -455,7 +430,7 @@ export function Note(props: NoteProps) {
455
430
  {card === null ? null : (
456
431
  <div
457
432
  ref={cardRef}
458
- id={PREVIEW_ID}
433
+ id={previewId}
459
434
  role="tooltip"
460
435
  class={`weave-preview${card.ghost ? " weave-preview-ghost" : ""}`}
461
436
  >
@@ -0,0 +1,172 @@
1
+ import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
2
+
3
+ export interface NoteDraft {
4
+ body: string;
5
+ baseline: string;
6
+ saving: boolean;
7
+ }
8
+
9
+ interface TaskSource {
10
+ body: string;
11
+ version: number;
12
+ pending?: { body: string; version: number };
13
+ saving: boolean;
14
+ }
15
+
16
+ export interface DraftStore {
17
+ get(slug: string): Readonly<NoteDraft> | null;
18
+ subscribe(listener: () => void): () => void;
19
+ open(slug: string, body: string): void;
20
+ edit(slug: string, body: string): void;
21
+ discard(slug: string): void;
22
+ isDirty(slug: string): boolean;
23
+ dirtySlugs(): string[];
24
+ save(slug: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<void>;
25
+ nextLoadVersion(): number;
26
+ getTaskBody(slug: string, fallback: string): string;
27
+ taskBody(slug: string, serverBody: string, loadVersion: number): string;
28
+ isTaskSaving(slug: string): boolean;
29
+ saveTask(slug: string, body: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<boolean>;
30
+ clearTaskSources(slugs: readonly string[]): void;
31
+ }
32
+
33
+ export function createDraftStore(): DraftStore {
34
+ const drafts = new Map<string, NoteDraft>();
35
+ const taskSources = new Map<string, TaskSource>();
36
+ const listeners = new Set<() => void>();
37
+ let loadVersion = 0;
38
+ const notify = (): void => listeners.forEach((listener) => listener());
39
+
40
+ return {
41
+ get: (slug) => drafts.get(slug) ?? null,
42
+ subscribe(listener) {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ },
46
+ open(slug, body) {
47
+ if (drafts.has(slug)) return;
48
+ drafts.set(slug, { body, baseline: body, saving: false });
49
+ notify();
50
+ },
51
+ edit(slug, body) {
52
+ const draft = drafts.get(slug);
53
+ if (draft === undefined || draft.body === body) return;
54
+ draft.body = body;
55
+ notify();
56
+ },
57
+ discard(slug) {
58
+ if (drafts.delete(slug)) notify();
59
+ },
60
+ isDirty(slug) {
61
+ const draft = drafts.get(slug);
62
+ return draft !== undefined && draft.body !== draft.baseline;
63
+ },
64
+ dirtySlugs() {
65
+ return [...drafts].filter(([, draft]) => draft.body !== draft.baseline).map(([slug]) => slug);
66
+ },
67
+ async save(slug, onSave) {
68
+ const draft = drafts.get(slug);
69
+ if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
70
+ const sent = draft.body;
71
+ draft.saving = true;
72
+ notify();
73
+ let ok = false;
74
+ try {
75
+ ok = await onSave(slug, sent);
76
+ } finally {
77
+ // Discard/reopen during an in-flight request gives the new draft a new identity.
78
+ if (drafts.get(slug) === draft) {
79
+ const source = taskSources.get(slug);
80
+ if (ok && source !== undefined) {
81
+ source.body = sent;
82
+ source.version = ++loadVersion;
83
+ delete source.pending;
84
+ }
85
+ if (ok && draft.body === sent) drafts.delete(slug);
86
+ else {
87
+ if (ok) draft.baseline = sent;
88
+ draft.saving = false;
89
+ }
90
+ notify();
91
+ }
92
+ }
93
+ },
94
+ nextLoadVersion() {
95
+ return ++loadVersion;
96
+ },
97
+ getTaskBody(slug, fallback) {
98
+ return taskSources.get(slug)?.body ?? fallback;
99
+ },
100
+ taskBody(slug, serverBody, version) {
101
+ let source = taskSources.get(slug);
102
+ let changed = false;
103
+ if (source === undefined) {
104
+ source = { body: serverBody, version, saving: false };
105
+ taskSources.set(slug, source);
106
+ } else if (version > source.version) {
107
+ if (source.saving) {
108
+ if (source.pending === undefined || version > source.pending.version) source.pending = { body: serverBody, version };
109
+ } else {
110
+ changed = source.body !== serverBody;
111
+ source.body = serverBody;
112
+ source.version = version;
113
+ }
114
+ }
115
+ if (changed) notify();
116
+ return source.body;
117
+ },
118
+ isTaskSaving: (slug) => taskSources.get(slug)?.saving ?? false,
119
+ async saveTask(slug, body, onSave) {
120
+ const source = taskSources.get(slug);
121
+ if (source === undefined || source.saving || drafts.get(slug)?.saving) return false;
122
+ source.saving = true;
123
+ notify();
124
+ let ok = false;
125
+ try {
126
+ ok = await onSave(slug, body);
127
+ return ok;
128
+ } finally {
129
+ if (taskSources.get(slug) === source) {
130
+ if (ok) {
131
+ source.body = body;
132
+ // Invalidate note loads that began before this save completed.
133
+ source.version = ++loadVersion;
134
+ } else if (source.pending !== undefined && source.pending.version > source.version) {
135
+ source.body = source.pending.body;
136
+ source.version = source.pending.version;
137
+ }
138
+ delete source.pending;
139
+ source.saving = false;
140
+ notify();
141
+ }
142
+ }
143
+ },
144
+ clearTaskSources(slugs) {
145
+ let changed = false;
146
+ for (const slug of slugs) changed = taskSources.delete(slug) || changed;
147
+ if (changed) notify();
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Removing the last selected view of a dirty note requires deliberate discard. */
153
+ export function mayRemoveDrafts(drafts: DraftStore, current: WorkspaceLayout, next: WorkspaceLayout, confirm: () => boolean): boolean {
154
+ const before = new Set(current.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
155
+ const after = new Set(next.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
156
+ const removed = [...before].filter((id): id is string => id !== null && id.startsWith("note:") && !after.has(id))
157
+ .map((id) => id.slice(5));
158
+ if (removed.some((slug) => drafts.isDirty(slug)) && !confirm()) return false;
159
+ removed.forEach((slug) => drafts.discard(slug));
160
+ drafts.clearTaskSources(removed);
161
+ return true;
162
+ }
163
+
164
+ /** Confirm only dirty drafts affected by a tree mutation, then clear affected state. */
165
+ export function mayMutateDrafts(drafts: DraftStore, affectedSlugs: readonly string[], confirm: () => boolean): boolean {
166
+ const affected = new Set(affectedSlugs);
167
+ const dirty = drafts.dirtySlugs().filter((slug) => affected.has(slug));
168
+ if (dirty.length > 0 && !confirm()) return false;
169
+ affected.forEach((slug) => drafts.discard(slug));
170
+ drafts.clearTaskSources([...affected]);
171
+ return true;
172
+ }