pi-weave 0.2.0 → 0.2.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.
@@ -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,
@@ -70,6 +87,18 @@ export interface NoteProps {
70
87
  selectedId: string | null;
71
88
  onSelect: (id: string) => void;
72
89
  onOpen: (slug: string) => void;
90
+ /**
91
+ * Save the note's body. Resolves `true` when the write landed; a `false`
92
+ * has already been reported to the user by the shell, which owns the one
93
+ * error-reporting gesture the client has.
94
+ */
95
+ onSave: (slug: string, body: string) => Promise<boolean>;
96
+ /**
97
+ * The shell's handle on the open editor — the `fit` pattern again. Guards
98
+ * both *ask* (navigate, unload) and *close* (a tree rename of this note).
99
+ * Functions, not values, or a mount-time guard answers "clean" forever.
100
+ */
101
+ editor: { current: { dirty(): boolean; discard(): void } | null };
73
102
  /** Epoch ms for relative times. Injected so the render is deterministic. */
74
103
  now: number;
75
104
  }
@@ -85,14 +114,32 @@ export interface NoteProps {
85
114
  function Header({
86
115
  view,
87
116
  open,
117
+ editing,
118
+ saving,
119
+ onDone,
120
+ onSave,
88
121
  }: {
89
122
  view: NoteHeaderView;
90
123
  open: () => void;
124
+ editing: boolean;
125
+ saving: boolean;
126
+ onDone: () => void;
127
+ onSave: () => void;
91
128
  }) {
92
129
  return (
93
130
  <header class="weave-note-head">
94
131
  <h3 class="weave-note-title">{view.title}</h3>
95
132
  <p class="weave-note-meta">
133
+ {/* Nothing in read mode: the prose is the way in. The way out needs a
134
+ control, because "click the text" cannot also mean "stop". */}
135
+ {editing ? (
136
+ <>
137
+ <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
138
+ {saving ? SAVING_LABEL : SAVE_LABEL}
139
+ </button>
140
+ <button type="button" class="weave-note-edit" onClick={onDone}>{DONE_LABEL}</button>
141
+ </>
142
+ ) : null}
96
143
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
97
144
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
98
145
  </button>
@@ -142,6 +189,55 @@ export function Note(props: NoteProps) {
142
189
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
143
190
  const bodyRef = useRef<HTMLDivElement | null>(null);
144
191
  const cardRef = useRef<HTMLDivElement | null>(null);
192
+
193
+ const [draft, setDraft] = useState<string | null>(null);
194
+ const [saving, setSaving] = useState(false);
195
+ const dirty = note !== null && draft !== null && draftDirty(draft, note.body);
196
+ // Bumped on open and close, so a late reply knows it is late (`saveLanded`).
197
+ const session = useRef(0);
198
+ // The draft *now*: `save` outlives the render that captured its own copy.
199
+ const draftRef = useRef(draft);
200
+ draftRef.current = draft;
201
+
202
+ // During render, not in an effect: an effect runs after paint, leaving a
203
+ // window where the slot is still clean and a click slips the guard.
204
+ props.editor.current = { dirty: () => dirty, discard: () => close() };
205
+ useEffect(() => () => {
206
+ props.editor.current = null;
207
+ }, [props.editor]);
208
+
209
+ const close = (): void => {
210
+ session.current += 1;
211
+ setDraft(null);
212
+ setSaving(false);
213
+ };
214
+
215
+ const open = (body: string): void => {
216
+ session.current += 1;
217
+ setDraft(body);
218
+ };
219
+
220
+ const save = async (): Promise<void> => {
221
+ if (note === null || draft === null || saving) return;
222
+ const issued = session.current;
223
+ // The textarea stays enabled through the request, so the draft can move
224
+ // underneath it; the reply is only ever about these bytes.
225
+ const sent = draft;
226
+ setSaving(true);
227
+ const ok = await props.onSave(note.slug, sent);
228
+ // A reply for an editor that is gone must not close the one now open.
229
+ if (!saveLanded(issued, session.current)) return;
230
+ // Stay open on failure, and on success the user typed through: both hold
231
+ // text the server does not have, and closing would discard it.
232
+ if (!ok || draftMoved(sent, draftRef.current)) setSaving(false);
233
+ else close();
234
+ };
235
+
236
+ const toggleEdit = (): void => {
237
+ if (note === null) return;
238
+ if (draft === null) open(note.body);
239
+ else if (!dirty || window.confirm(DISCARD_PROMPT)) close();
240
+ };
145
241
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
146
242
 
147
243
  // A card whose target note left the screen is a claim about a document that
@@ -152,6 +248,10 @@ export function Note(props: NoteProps) {
152
248
  // slug changes as rarely as navigation happens.
153
249
  useLayoutEffect(() => {
154
250
  dispatch({ type: "hide" });
251
+ // A different note is a different document: an open editor holding the
252
+ // previous one's text would save it over this one. The shell's guard is
253
+ // what asks before a dirty draft gets here.
254
+ close();
155
255
  }, [note === null ? null : note.slug]);
156
256
 
157
257
  // Wired after the card mounts, because both jobs depend on the live DOM:
@@ -223,7 +323,32 @@ export function Note(props: NoteProps) {
223
323
  <Header
224
324
  view={header}
225
325
  open={() => props.onOpen(note.slug)}
326
+ editing={draft !== null}
327
+ saving={saving}
328
+ onDone={toggleEdit}
329
+ onSave={() => void save()}
226
330
  />
331
+ {draft !== null ? (
332
+ <textarea
333
+ class="weave-note-editor"
334
+ aria-label={EDITOR_ARIA_LABEL}
335
+ spellcheck
336
+ value={draft}
337
+ onInput={(event) => setDraft((event.target as HTMLTextAreaElement).value)}
338
+ onKeyDown={(event) => {
339
+ // Bound here, not in `keys.model.ts`: a global binding is a
340
+ // workspace-wide claim, and Escape must not reach the shell.
341
+ if (event.key === "Escape") {
342
+ event.stopPropagation();
343
+ toggleEdit();
344
+ return;
345
+ }
346
+ if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
347
+ event.preventDefault();
348
+ void save();
349
+ }}
350
+ />
351
+ ) : (
227
352
  <div
228
353
  ref={bodyRef}
229
354
  class="weave-note-body"
@@ -255,19 +380,16 @@ export function Note(props: NoteProps) {
255
380
  }}
256
381
  onBlur={() => dispatch({ type: "hide" })}
257
382
  onClick={(event) => {
258
- // Selecting text to copy must not trigger wikilink navigation.
383
+ // Three gestures, one element; `noteClickAction` owns the order.
259
384
  const selection =
260
385
  typeof window !== "undefined" && typeof window.getSelection === "function"
261
386
  ? window.getSelection()
262
387
  : null;
263
- if (hasTextSelection(selection)) return;
264
-
265
- // A wikilink carries no href, so route it onto the context bus.
266
388
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
267
- if (target !== null) {
268
- props.onSelect(target);
269
- return;
270
- }
389
+ const action = noteClickAction(hasTextSelection(selection), target);
390
+ // A wikilink carries no href, so route it onto the context bus.
391
+ if (action === "navigate" && target !== null) props.onSelect(target);
392
+ else if (action === "edit") open(note.body);
271
393
  }}
272
394
  onKeyDown={(event) => {
273
395
  // Escape is first so the card closes on the gesture a keyboard
@@ -282,13 +404,16 @@ export function Note(props: NoteProps) {
282
404
  }
283
405
  if (event.key !== "Enter" && event.key !== " ") return;
284
406
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
285
- if (target === null) return;
286
407
  event.preventDefault();
287
- props.onSelect(target);
408
+ // The keyboard's half of the click: without it, editing would be
409
+ // mouse-only now that the Edit button is gone.
410
+ if (target !== null) props.onSelect(target);
411
+ else open(note.body);
288
412
  }}
289
413
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
290
414
  dangerouslySetInnerHTML={{ __html: html }}
291
415
  />
416
+ )}
292
417
  {card === null ? null : (
293
418
  <div
294
419
  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;
@@ -40,6 +40,15 @@ export interface ColumnsProps {
40
40
  onSelect: (id: string | null) => void;
41
41
  onRefresh: () => void;
42
42
  onOpen: (slug: string) => void;
43
+ /** Save a note's body. Resolves `true` when the write landed. */
44
+ onSave: (slug: string, body: string) => Promise<boolean>;
45
+ /**
46
+ * Ask before a mutation that would invalidate an open draft, closing it if
47
+ * the user agrees. `false` means keep editing: do not proceed.
48
+ */
49
+ onMutate: () => boolean;
50
+ /** Slot the note column fills with its editor handle, for the shell's guards. */
51
+ editor: { current: { dirty(): boolean; discard(): void } | null };
43
52
  /** Epoch ms for relative times, read once per render by the shell. */
44
53
  now: number;
45
54
  /**
@@ -75,7 +84,7 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
75
84
  return (
76
85
  <section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
77
86
  <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}
87
+ {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
88
  {id === "note" ? (
80
89
  <Note
81
90
  note={props.note}
@@ -84,6 +93,8 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
84
93
  onSelect={props.onSelect}
85
94
  now={props.now}
86
95
  onOpen={props.onOpen}
96
+ onSave={props.onSave}
97
+ editor={props.editor}
87
98
  />
88
99
  ) : null}
89
100
  {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
  }),
@@ -201,9 +234,27 @@ export function Shell(props: ShellProps) {
201
234
  note={workspaceState.note}
202
235
  selectedId={workspaceState.selectedId}
203
236
  recentIds={workspaceState.recentIds}
204
- onSelect={(id) => void workspace.current?.select(id)}
237
+ onSelect={select}
238
+ onMutate={mayDiscard}
205
239
  onRefresh={() => workspace.current?.refresh()}
206
- onOpen={(slug) => void openNote(fetchJson, slug)}
240
+ onOpen={(slug) => void (async () => {
241
+ // Reported, not discarded: a 403 and an editor that opened nothing
242
+ // used to look identical. `alert` is what the tree already uses.
243
+ const result = await openNote(fetchJson, slug);
244
+ if (!result.ok) window.alert(result.message);
245
+ else if (!result.data.opened) window.alert("could not open the note in an editor");
246
+ })()}
247
+ onSave={async (slug, body) => {
248
+ const result = await saveNote(fetchJson, slug, body);
249
+ if (!result.ok) {
250
+ window.alert(result.message);
251
+ return false;
252
+ }
253
+ // The poll would get there in two seconds; this agrees immediately.
254
+ workspace.current?.refresh();
255
+ return true;
256
+ }}
257
+ editor={editor}
207
258
  now={now}
208
259
  // The graph column's three ports (§7.5, §10). Supplied here, at the
209
260
  // one place that is already allowed to name browser globals, so
@@ -227,7 +278,7 @@ export function Shell(props: ShellProps) {
227
278
  {overlay === "search" ? (
228
279
  <SearchPalette
229
280
  graph={workspaceState.graph}
230
- onSelect={(id) => void workspace.current?.select(id)}
281
+ onSelect={select}
231
282
  onClose={() => setOverlay(null)}
232
283
  ports={{ fetch: fetchJson }}
233
284
  />
@@ -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{
@@ -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>
@@ -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);