pi-weave 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The read-only note column (weave-workspace §1.2, P2.4, P6.3).
2
+ * The note column — reading, and editing (weave-workspace §1.2, P2.4, P5, P6.3).
3
3
  *
4
4
  * Props in, JSX out. The markdown pipeline, sanitiser config, wikilink
5
5
  * resolution and every string are in `note.model.ts`. This file wires the
6
- * rendered body, selection-safe wikilink navigation, and the Open in
7
- * `$EDITOR` action.
6
+ * rendered body, selection-safe wikilink navigation, the Open in `$EDITOR`
7
+ * action, and the body editor.
8
+ *
9
+ * ## The editor
10
+ *
11
+ * Clicking the prose swaps it for a `<textarea>`; `draft` is `null` in read
12
+ * mode, so text and mode cannot disagree. No reducer — the 686-line one that
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.
8
16
  *
9
17
  * `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
10
18
  * alternative is parsing marked's output into a Preact tree, which means a
@@ -31,16 +39,25 @@
31
39
  */
32
40
 
33
41
  import DOMPurify from "dompurify";
34
- import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
42
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
35
43
  import type { GraphPayload, NotePayload } from "../../shared/wire";
36
44
  import {
37
45
  CREATED_WORD,
38
46
  artifactKeyOfNode,
47
+ DISCARD_PROMPT,
48
+ DONE_LABEL,
39
49
  EDITED_WORD,
50
+ EDITOR_ARIA_LABEL,
40
51
  EMPTY_PREVIEW,
41
52
  PREVIEW_ID,
53
+ SAVE_LABEL,
54
+ SAVING_LABEL,
42
55
  WIKILINK_ATTR,
56
+ draftDirty,
57
+ draftMoved,
43
58
  hasTextSelection,
59
+ noteClickAction,
60
+ saveLanded,
44
61
  noteEmptyMessage,
45
62
  noteHeader,
46
63
  previewAnchorOf,
@@ -66,10 +83,23 @@ const PREVIEW_Y = "weave-preview-y";
66
83
 
67
84
  export interface NoteProps {
68
85
  note: NotePayload | null;
86
+ loadFailed: boolean;
69
87
  graph: GraphPayload | null;
70
88
  selectedId: string | null;
71
89
  onSelect: (id: string) => void;
72
90
  onOpen: (slug: string) => void;
91
+ /**
92
+ * Save the note's body. Resolves `true` when the write landed; a `false`
93
+ * has already been reported to the user by the shell, which owns the one
94
+ * error-reporting gesture the client has.
95
+ */
96
+ onSave: (slug: string, body: string) => Promise<boolean>;
97
+ /**
98
+ * The shell's handle on the open editor — the `fit` pattern again. Guards
99
+ * both *ask* (navigate, unload) and *close* (a tree rename of this note).
100
+ * Functions, not values, or a mount-time guard answers "clean" forever.
101
+ */
102
+ editor: { current: { dirty(): boolean; discard(): void } | null };
73
103
  /** Epoch ms for relative times. Injected so the render is deterministic. */
74
104
  now: number;
75
105
  }
@@ -85,14 +115,32 @@ export interface NoteProps {
85
115
  function Header({
86
116
  view,
87
117
  open,
118
+ editing,
119
+ saving,
120
+ onDone,
121
+ onSave,
88
122
  }: {
89
123
  view: NoteHeaderView;
90
124
  open: () => void;
125
+ editing: boolean;
126
+ saving: boolean;
127
+ onDone: () => void;
128
+ onSave: () => void;
91
129
  }) {
92
130
  return (
93
131
  <header class="weave-note-head">
94
132
  <h3 class="weave-note-title">{view.title}</h3>
95
133
  <p class="weave-note-meta">
134
+ {/* Nothing in read mode: the prose is the way in. The way out needs a
135
+ control, because "click the text" cannot also mean "stop". */}
136
+ {editing ? (
137
+ <>
138
+ <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
139
+ {saving ? SAVING_LABEL : SAVE_LABEL}
140
+ </button>
141
+ <button type="button" class="weave-note-edit" onClick={onDone}>{DONE_LABEL}</button>
142
+ </>
143
+ ) : null}
96
144
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
97
145
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
98
146
  </button>
@@ -120,7 +168,7 @@ function Header({
120
168
  export function Note(props: NoteProps) {
121
169
  const note = props.note?.note ?? null;
122
170
  const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
123
- const empty = noteEmptyMessage(props.selectedId, note);
171
+ const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
124
172
 
125
173
  // Both hook calls sit before the empty-return so the hook order cannot
126
174
  // depend on whether a note is loaded. The memoization is cheap insurance:
@@ -142,6 +190,55 @@ export function Note(props: NoteProps) {
142
190
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
143
191
  const bodyRef = useRef<HTMLDivElement | null>(null);
144
192
  const cardRef = useRef<HTMLDivElement | null>(null);
193
+
194
+ const [draft, setDraft] = useState<string | null>(null);
195
+ const [saving, setSaving] = useState(false);
196
+ const dirty = note !== null && draft !== null && draftDirty(draft, note.body);
197
+ // Bumped on open and close, so a late reply knows it is late (`saveLanded`).
198
+ const session = useRef(0);
199
+ // The draft *now*: `save` outlives the render that captured its own copy.
200
+ const draftRef = useRef(draft);
201
+ draftRef.current = draft;
202
+
203
+ // During render, not in an effect: an effect runs after paint, leaving a
204
+ // window where the slot is still clean and a click slips the guard.
205
+ props.editor.current = { dirty: () => dirty, discard: () => close() };
206
+ useEffect(() => () => {
207
+ props.editor.current = null;
208
+ }, [props.editor]);
209
+
210
+ const close = (): void => {
211
+ session.current += 1;
212
+ setDraft(null);
213
+ setSaving(false);
214
+ };
215
+
216
+ const open = (body: string): void => {
217
+ session.current += 1;
218
+ setDraft(body);
219
+ };
220
+
221
+ const save = async (): Promise<void> => {
222
+ if (note === null || draft === null || saving) return;
223
+ const issued = session.current;
224
+ // The textarea stays enabled through the request, so the draft can move
225
+ // underneath it; the reply is only ever about these bytes.
226
+ const sent = draft;
227
+ setSaving(true);
228
+ const ok = await props.onSave(note.slug, sent);
229
+ // A reply for an editor that is gone must not close the one now open.
230
+ if (!saveLanded(issued, session.current)) return;
231
+ // Stay open on failure, and on success the user typed through: both hold
232
+ // text the server does not have, and closing would discard it.
233
+ if (!ok || draftMoved(sent, draftRef.current)) setSaving(false);
234
+ else close();
235
+ };
236
+
237
+ const toggleEdit = (): void => {
238
+ if (note === null) return;
239
+ if (draft === null) open(note.body);
240
+ else if (!dirty || window.confirm(DISCARD_PROMPT)) close();
241
+ };
145
242
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
146
243
 
147
244
  // A card whose target note left the screen is a claim about a document that
@@ -152,6 +249,10 @@ export function Note(props: NoteProps) {
152
249
  // slug changes as rarely as navigation happens.
153
250
  useLayoutEffect(() => {
154
251
  dispatch({ type: "hide" });
252
+ // A different note is a different document: an open editor holding the
253
+ // previous one's text would save it over this one. The shell's guard is
254
+ // what asks before a dirty draft gets here.
255
+ close();
155
256
  }, [note === null ? null : note.slug]);
156
257
 
157
258
  // Wired after the card mounts, because both jobs depend on the live DOM:
@@ -223,7 +324,32 @@ export function Note(props: NoteProps) {
223
324
  <Header
224
325
  view={header}
225
326
  open={() => props.onOpen(note.slug)}
327
+ editing={draft !== null}
328
+ saving={saving}
329
+ onDone={toggleEdit}
330
+ onSave={() => void save()}
226
331
  />
332
+ {draft !== null ? (
333
+ <textarea
334
+ class="weave-note-editor"
335
+ aria-label={EDITOR_ARIA_LABEL}
336
+ spellcheck
337
+ value={draft}
338
+ onInput={(event) => setDraft((event.target as HTMLTextAreaElement).value)}
339
+ onKeyDown={(event) => {
340
+ // Bound here, not in `keys.model.ts`: a global binding is a
341
+ // workspace-wide claim, and Escape must not reach the shell.
342
+ if (event.key === "Escape") {
343
+ event.stopPropagation();
344
+ toggleEdit();
345
+ return;
346
+ }
347
+ if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
348
+ event.preventDefault();
349
+ void save();
350
+ }}
351
+ />
352
+ ) : (
227
353
  <div
228
354
  ref={bodyRef}
229
355
  class="weave-note-body"
@@ -255,19 +381,16 @@ export function Note(props: NoteProps) {
255
381
  }}
256
382
  onBlur={() => dispatch({ type: "hide" })}
257
383
  onClick={(event) => {
258
- // Selecting text to copy must not trigger wikilink navigation.
384
+ // Three gestures, one element; `noteClickAction` owns the order.
259
385
  const selection =
260
386
  typeof window !== "undefined" && typeof window.getSelection === "function"
261
387
  ? window.getSelection()
262
388
  : null;
263
- if (hasTextSelection(selection)) return;
264
-
265
- // A wikilink carries no href, so route it onto the context bus.
266
389
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
267
- if (target !== null) {
268
- props.onSelect(target);
269
- return;
270
- }
390
+ const action = noteClickAction(hasTextSelection(selection), target);
391
+ // A wikilink carries no href, so route it onto the context bus.
392
+ if (action === "navigate" && target !== null) props.onSelect(target);
393
+ else if (action === "edit") open(note.body);
271
394
  }}
272
395
  onKeyDown={(event) => {
273
396
  // Escape is first so the card closes on the gesture a keyboard
@@ -282,13 +405,16 @@ export function Note(props: NoteProps) {
282
405
  }
283
406
  if (event.key !== "Enter" && event.key !== " ") return;
284
407
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
285
- if (target === null) return;
286
408
  event.preventDefault();
287
- props.onSelect(target);
409
+ // The keyboard's half of the click: without it, editing would be
410
+ // mouse-only now that the Edit button is gone.
411
+ if (target !== null) props.onSelect(target);
412
+ else open(note.body);
288
413
  }}
289
414
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
290
415
  dangerouslySetInnerHTML={{ __html: html }}
291
416
  />
417
+ )}
292
418
  {card === null ? null : (
293
419
  <div
294
420
  ref={cardRef}
@@ -745,6 +745,57 @@ export function hasTextSelection(selection: SelectionLike | null | undefined): b
745
745
  export const EDITED_WORD = "edited";
746
746
  export const CREATED_WORD = "created";
747
747
 
748
+ // --- the editor -------------------------------------------------------------------------
749
+
750
+ /**
751
+ * The editor's strings. No "Edit" label — clicking the prose opens it (see
752
+ * {@link noteClickAction}); "Done", because leaving a clean editor cancels
753
+ * nothing.
754
+ */
755
+ export const DONE_LABEL = "Done";
756
+ export const SAVE_LABEL = "Save";
757
+ export const SAVING_LABEL = "Saving…";
758
+ export const EDITOR_ARIA_LABEL = "Note body";
759
+ export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
760
+
761
+ /**
762
+ * Whether the draft differs from the note as last loaded. Trailing whitespace
763
+ * counts: the save trims, so calling such a draft clean would skip the prompt
764
+ * on the one edit that gets silently reverted.
765
+ */
766
+ export function draftDirty(draft: string, body: string): boolean {
767
+ return draft !== body;
768
+ }
769
+
770
+ /** What a click on the rendered body means. */
771
+ export type NoteClickAction = "ignore" | "navigate" | "edit";
772
+
773
+ /**
774
+ * Resolve a click on the prose. Selection wins (a drag to copy ends in a
775
+ * click, and 865da37 is that bug), then wikilinks, then editing.
776
+ */
777
+ export function noteClickAction(hasSelection: boolean, wikilinkTarget: string | null): NoteClickAction {
778
+ if (hasSelection) return "ignore";
779
+ return wikilinkTarget === null ? "edit" : "navigate";
780
+ }
781
+
782
+ /**
783
+ * Whether a save's reply still belongs to the editor that issued it. A
784
+ * counter, not the slug: reopening the same note is a new session too.
785
+ */
786
+ export function saveLanded(issued: number, current: number): boolean {
787
+ return issued === current;
788
+ }
789
+
790
+ /**
791
+ * Whether the draft moved since the bytes a save sent — keystrokes made
792
+ * during the request were never in it, so a success must not close over them.
793
+ * Unlike {@link saveLanded}, this is about the *text*, not the session.
794
+ */
795
+ export function draftMoved(sent: string, current: string | null): boolean {
796
+ return current !== null && current !== sent;
797
+ }
798
+
748
799
  /** The note header: title, provenance badge, relative time, tags. */
749
800
  export interface NoteHeaderView {
750
801
  readonly title: string;
@@ -1100,9 +1151,10 @@ export function reducePreview(state: PreviewState, event: PreviewEvent): Preview
1100
1151
  * message for all of them is how a user concludes the app is broken when they
1101
1152
  * click a module and the reading pane does not change.
1102
1153
  */
1103
- export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
1154
+ export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null, failed = false): string | null {
1104
1155
  if (note !== null) return null;
1105
1156
  if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
1157
+ if (failed) return "Couldn’t load this note — retrying…";
1106
1158
  if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
1107
1159
  // A module, a file, a package, the git state: real nodes with no prose
1108
1160
  // behind them. The context rail beside this column is where their
@@ -26,6 +26,7 @@ export interface ColumnsProps {
26
26
  /** The §1.3 context bus, as the columns see it. */
27
27
  graph: GraphPayload | null;
28
28
  note: NotePayload | null;
29
+ noteFailed: boolean;
29
30
  selectedId: string | null;
30
31
  recentIds: ReadonlySet<string>;
31
32
  /**
@@ -40,6 +41,15 @@ export interface ColumnsProps {
40
41
  onSelect: (id: string | null) => void;
41
42
  onRefresh: () => void;
42
43
  onOpen: (slug: string) => void;
44
+ /** Save a note's body. Resolves `true` when the write landed. */
45
+ onSave: (slug: string, body: string) => Promise<boolean>;
46
+ /**
47
+ * Ask before a mutation that would invalidate an open draft, closing it if
48
+ * the user agrees. `false` means keep editing: do not proceed.
49
+ */
50
+ onMutate: () => boolean;
51
+ /** Slot the note column fills with its editor handle, for the shell's guards. */
52
+ editor: { current: { dirty(): boolean; discard(): void } | null };
43
53
  /** Epoch ms for relative times, read once per render by the shell. */
44
54
  now: number;
45
55
  /**
@@ -75,15 +85,18 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
75
85
  return (
76
86
  <section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
77
87
  <h2 class="weave-col-title">{copy.title}</h2>
78
- {id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} recentIds={props.recentIds} onSelect={props.onSelect} onRefresh={props.onRefresh} now={props.now} /> : null}
88
+ {id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} recentIds={props.recentIds} onSelect={props.onSelect} onMutate={props.onMutate} onRefresh={props.onRefresh} now={props.now} /> : null}
79
89
  {id === "note" ? (
80
90
  <Note
81
91
  note={props.note}
92
+ loadFailed={props.noteFailed}
82
93
  graph={props.graph}
83
94
  selectedId={props.selectedId}
84
95
  onSelect={props.onSelect}
85
96
  now={props.now}
86
97
  onOpen={props.onOpen}
98
+ onSave={props.onSave}
99
+ editor={props.editor}
87
100
  />
88
101
  ) : null}
89
102
  {id === "graph" ? (
@@ -19,7 +19,8 @@
19
19
 
20
20
  import { useEffect, useMemo, useRef, useState } from "preact/hooks";
21
21
  import { fetchJson } from "../api.dom";
22
- import { openNote } from "../api";
22
+ import { openNote, saveNote } from "../api";
23
+ import { DISCARD_PROMPT } from "../note/note.model";
23
24
  import type { ColorScheme } from "../graph/graph.model";
24
25
  import { schemeOf, watchScheme } from "../graph/scheme";
25
26
  import { createSigmaRenderer } from "../graph/renderer.dom";
@@ -83,6 +84,26 @@ export function Shell(props: ShellProps) {
83
84
  // Filled by the graph column at mount, cleared on unmount. The global `g`
84
85
  // key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
85
86
  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);
106
+ };
86
107
  // The global key listener reads through this so a handler registered at
87
108
  // mount still sees the current overlay.
88
109
  const live = useRef({ overlay, selectedId: workspaceState.selectedId, layout, width });
@@ -155,6 +176,18 @@ export function Shell(props: ShellProps) {
155
176
  // choice) keep up while the choice is "system".
156
177
  useEffect(() => watchScheme(window, setSystemScheme), []);
157
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.
181
+ useEffect(() => {
182
+ const guard = (event: BeforeUnloadEvent): void => {
183
+ if (editor.current?.dirty() !== true) return;
184
+ event.preventDefault();
185
+ event.returnValue = "";
186
+ };
187
+ window.addEventListener("beforeunload", guard);
188
+ return () => window.removeEventListener("beforeunload", guard);
189
+ }, []);
190
+
158
191
  useEffect(
159
192
  () =>
160
193
  watchKeys(document, {
@@ -164,7 +197,7 @@ export function Shell(props: ShellProps) {
164
197
  setOverlay,
165
198
  focusSelector: (selector) => focusSelector(document, selector),
166
199
  fitGraph: () => fit.current?.(),
167
- clearSelection: () => { void workspace.current?.select(null); },
200
+ clearSelection: () => select(null),
168
201
  cycleTheme: () => setTheme((current) => cycleTheme(current)),
169
202
  }),
170
203
  }),
@@ -199,11 +232,30 @@ export function Shell(props: ShellProps) {
199
232
  bootFailed={workspaceState.graphFailed}
200
233
  graph={workspaceState.graph}
201
234
  note={workspaceState.note}
235
+ noteFailed={workspaceState.noteFailed}
202
236
  selectedId={workspaceState.selectedId}
203
237
  recentIds={workspaceState.recentIds}
204
- onSelect={(id) => void workspace.current?.select(id)}
238
+ onSelect={select}
239
+ onMutate={mayDiscard}
205
240
  onRefresh={() => workspace.current?.refresh()}
206
- onOpen={(slug) => void openNote(fetchJson, slug)}
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}
207
259
  now={now}
208
260
  // The graph column's three ports (§7.5, §10). Supplied here, at the
209
261
  // one place that is already allowed to name browser globals, so
@@ -227,7 +279,7 @@ export function Shell(props: ShellProps) {
227
279
  {overlay === "search" ? (
228
280
  <SearchPalette
229
281
  graph={workspaceState.graph}
230
- onSelect={(id) => void workspace.current?.select(id)}
282
+ onSelect={select}
231
283
  onClose={() => setOverlay(null)}
232
284
  ports={{ fetch: fetchJson }}
233
285
  />
@@ -411,6 +411,25 @@ body{font-size:var(--weave-px-base)}
411
411
  }
412
412
  .weave-note-open:hover{color:var(--weave-fg);background:var(--weave-line)}
413
413
  .weave-note-open-mark{display:inline-flex;line-height:0}
414
+ /* Save / Done, chip-shaped: the same quiet row, and a control in a reading
415
+ column should not outweigh the title. */
416
+ .weave-note-edit,.weave-note-save{
417
+ font:inherit;font-size:var(--weave-px-caption);line-height:1;white-space:nowrap;cursor:pointer;
418
+ color:var(--weave-dim);background:var(--weave-panel);padding:4px 7px;
419
+ border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
420
+ }
421
+ .weave-note-edit:hover,.weave-note-save:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
422
+ /* Save is the row's only affirmative action, so the only one with the accent. */
423
+ .weave-note-save{color:var(--weave-accent);border-color:var(--weave-accent)}
424
+ .weave-note-save:disabled{color:var(--weave-faint);border-color:var(--weave-line-strong);cursor:default}
425
+ /* Mono and a tighter line than the prose: this is Markdown source, where
426
+ alignment carries meaning proportional text throws away. */
427
+ .weave-note-editor{
428
+ display:block;width:100%;flex:1;min-height:0;resize:none;
429
+ padding:12px var(--weave-note-gutter) 28px;box-sizing:border-box;
430
+ font-family:var(--weave-mono);font-size:var(--weave-px-row);line-height:1.6;
431
+ color:var(--weave-fg);background:var(--weave-bg);border:0;outline:0;
432
+ }
414
433
  .weave-note-tags{margin:6px 0 0;display:flex;gap:5px;flex-wrap:wrap}
415
434
  .weave-tag{
416
435
  font-size:var(--weave-px-caption);color:var(--weave-accent);background:var(--weave-panel);
@@ -422,6 +441,11 @@ body{font-size:var(--weave-px-base)}
422
441
  chrome's 1.6: the extra leading is what a page of running text needs that a
423
442
  status bar does not. Code stays mono deliberately. */
424
443
  .weave-note-body{padding:12px var(--weave-note-gutter) 28px;font-size:var(--weave-px-body);line-height:1.7;color:var(--weave-fg)}
444
+ /* The prose is the edit affordance: a caret and a faint wash, not a border.
445
+ This column is read far more often than edited. */
446
+ .weave-note-body{cursor:text}
447
+ .weave-note-body:hover{background:var(--weave-panel)}
448
+ @media (prefers-reduced-motion: no-preference){.weave-note-body{transition:background 120ms ease}}
425
449
  .weave-note-body>*:first-child{margin-top:0}
426
450
  .weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
427
451
  .weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
@@ -11,10 +11,11 @@ export interface WorkspaceState {
11
11
  graph: GraphPayload | null;
12
12
  graphFailed: boolean;
13
13
  note: NotePayload | null;
14
+ noteFailed: boolean;
14
15
  recentIds: ReadonlySet<string>;
15
16
  selectedId: string | null;
16
17
  }
17
18
 
18
19
  export function initialWorkspaceState(): WorkspaceState {
19
- return { graph: null, graphFailed: false, note: null, recentIds: new Set(), selectedId: null };
20
+ return { graph: null, graphFailed: false, note: null, noteFailed: false, recentIds: new Set(), selectedId: null };
20
21
  }
@@ -45,6 +45,11 @@ export interface TreeProps {
45
45
  selectedId: string | null;
46
46
  recentIds: ReadonlySet<string>;
47
47
  onSelect: (id: string) => void;
48
+ /**
49
+ * Ask before a mutation that would invalidate an open draft. `false` means
50
+ * keep editing: the mutation must not run.
51
+ */
52
+ onMutate: () => boolean;
48
53
  onRefresh: () => void;
49
54
  now: number;
50
55
  }
@@ -191,6 +196,9 @@ export function Tree(props: TreeProps) {
191
196
  } else if (action === "rename") {
192
197
  setEditing({ id: menu.id, label: menu.label, value: menu.label });
193
198
  } else if (target.type !== "vault") {
199
+ // Before the request: asking after a delete is asking about a file that
200
+ // is already gone, and "keep editing" would strand the draft on a 404.
201
+ if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
194
202
  await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
195
203
  }
196
204
  };
@@ -237,7 +245,9 @@ export function Tree(props: TreeProps) {
237
245
  // unactionable — it named no link and offered no way to see them — and it
238
246
  // fired on the *commit* of an edit the user had already typed out, which
239
247
  // is the least useful moment to ask. Renaming back is one more rename.
248
+ // An open draft is a different question; `onMutate` asks that one.
240
249
  if (target !== null && name && name !== current.label) {
250
+ if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
241
251
  void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
242
252
  }
243
253
  };
@@ -268,7 +278,7 @@ export function Tree(props: TreeProps) {
268
278
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
269
279
  }}
270
280
  >
271
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); }} />)}
281
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { if (dragged === props.selectedId && !props.onMutate()) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
272
282
  </ul>
273
283
  ) : <p class="weave-tree-empty">{empty}</p>}
274
284
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -54,11 +54,12 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
54
54
  const loadNote = async (): Promise<void> => {
55
55
  const slug = noteSlug(state.selectedId);
56
56
  if (slug === null) {
57
- update({ note: null });
57
+ update({ note: null, noteFailed: false });
58
58
  return;
59
59
  }
60
60
  const result = await fetchNote(opts.fetch, slug);
61
- if (result.ok) update({ note: result.data });
61
+ if (result.ok) update({ note: result.data, noteFailed: false });
62
+ else update({ note: null, noteFailed: true });
62
63
  };
63
64
  const loadGraph = async (): Promise<ApiResult<unknown>> => {
64
65
  const previous = state.graph;
@@ -86,7 +87,7 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
86
87
  polling = true;
87
88
  try {
88
89
  const result = await loadGraph();
89
- if (result.ok && !result.cached && noteSlug(state.selectedId) !== null) await loadNote();
90
+ if (noteSlug(state.selectedId) !== null && (state.note === null || (result.ok && !result.cached))) await loadNote();
90
91
  } finally {
91
92
  polling = false;
92
93
  }
@@ -7,6 +7,7 @@
7
7
  * | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
8
8
  * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
9
  * | GET | `/api/note/:slug` | {@link NotePayload} |
10
+ * | POST | `/api/note/:slug` | save a note's Markdown body |
10
11
  * | POST | `/api/note/:slug/rename` | rename a vault note |
11
12
  * | POST | `/api/note/:slug/move` | move a vault note |
12
13
  * | DELETE | `/api/note/:slug` | delete a vault note |
@@ -52,7 +53,7 @@ import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
52
53
  import { openNoteInEditor } from "../../core/openInEditor";
53
54
  import type { Note } from "../../core/types";
54
55
  import type { VaultMutationResult } from "../../core/vault";
55
- import { createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes } from "../../core/vault";
56
+ import { createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
56
57
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
57
58
  import type {
58
59
  GraphPayload,
@@ -555,18 +556,29 @@ async function routeNote(
555
556
  }
556
557
  const action = target.endsWith("/rename") ? "rename" : target.endsWith("/move") ? "move" : null;
557
558
  const slug = action === null ? target : target.slice(0, -(action.length + 1));
558
- if (method === "POST" && action !== null) {
559
+ if (method === "POST") {
559
560
  const body = await readJsonBody(req);
560
561
  if (typeof body !== "object" || body === null) {
561
562
  sendJson(res, 400, { error: "expected JSON object" });
562
563
  return true;
563
564
  }
565
+ const fields = body as { body?: unknown; name?: unknown; folder?: unknown };
566
+ // The payload decides, not the suffix: slugs nest, so `archive/rename` is
567
+ // a real note and only `{name}`/`{folder}` mean the action.
568
+ if (typeof fields.body === "string") {
569
+ sendMutation(res, await setNoteBody(deps.vaultRoot, target, fields.body));
570
+ return true;
571
+ }
572
+ if (action === null) {
573
+ sendJson(res, 400, { error: "expected { body: string }" });
574
+ return true;
575
+ }
564
576
  const result = action === "rename"
565
- ? typeof (body as { name?: unknown }).name === "string"
566
- ? await renameNote(deps.vaultRoot, slug, (body as { name: string }).name)
577
+ ? typeof fields.name === "string"
578
+ ? await renameNote(deps.vaultRoot, slug, fields.name)
567
579
  : null
568
- : (body as { folder?: unknown }).folder === null || typeof (body as { folder?: unknown }).folder === "string"
569
- ? await moveNote(deps.vaultRoot, slug, (body as { folder: string | null }).folder)
580
+ : fields.folder === null || typeof fields.folder === "string"
581
+ ? await moveNote(deps.vaultRoot, slug, fields.folder)
570
582
  : null;
571
583
  if (result === null) sendJson(res, 400, { error: action === "rename" ? "expected { name: string }" : "expected { folder: string | null }" });
572
584
  else sendMutation(res, result);