pi-weave 0.3.9 → 0.3.10

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.
@@ -51,8 +51,6 @@ import {
51
51
  EDITOR_ARIA_LABEL,
52
52
  EDITOR_PLACEHOLDER,
53
53
  EMPTY_PREVIEW,
54
- SAVE_LABEL,
55
- SAVING_LABEL,
56
54
  TASK_CHECKBOX_ATTR,
57
55
  WIKILINK_ATTR,
58
56
  hasTextSelection,
@@ -81,6 +79,20 @@ import type { DraftStore, SaveNote } from "./drafts";
81
79
  import { ReplayBar, wrapMarks } from "./Replay";
82
80
  import type { ReplayState } from "./Replay";
83
81
  import { HISTORY_CLOSE_LABEL, HISTORY_LABEL, PLAY_INTERVAL_MS, nextStep, replayStep } from "./replay.model";
82
+ import {
83
+ AUTOSAVE_MS,
84
+ DISCARD_EDITS_LABEL,
85
+ EDITING_LABEL,
86
+ FORMAT_TOOLS,
87
+ REVERT_PROMPT,
88
+ RETRY_LABEL,
89
+ applyFormat,
90
+ editStatus,
91
+ formatForKey,
92
+ replacedSpan,
93
+ } from "./editbar.model";
94
+ import type { EditStatus, FormatKind } from "./editbar.model";
95
+ import { Icon } from "../tree/Tree";
84
96
 
85
97
  /**
86
98
  * The custom properties the preview card is placed with, written by the
@@ -122,75 +134,134 @@ export interface NoteProps {
122
134
  }
123
135
 
124
136
  /**
125
- * Title, one quiet meta line, tags.
137
+ * Folder crumb, title, one quiet meta line, tags.
126
138
  *
127
139
  * The hierarchy is the point: the title is the page's largest voice and the
128
- * meta line is its footnote — provenance glyph and word, edited, created.
129
- * The Open in `$EDITOR` action sits at the end of that line as an icon, where
130
- * a workflow control belongs in a column whose job is reading.
140
+ * meta line is its footnote, in plain words: who wrote it and when it last
141
+ * changed. The note's own actions (History, Edit, Open in `$EDITOR`) sit at
142
+ * the end of that line, where a workflow control belongs in a column whose job
143
+ * is reading. While editing, the line gives way to the edit bar above.
131
144
  */
132
145
  function Header({
133
146
  view,
134
147
  open,
135
148
  editing,
136
- saving,
137
149
  taskSaving,
138
- onToggle,
139
- onSave,
150
+ onEdit,
140
151
  replaying,
141
152
  onHistory,
142
153
  }: {
143
154
  view: NoteHeaderView;
144
155
  open: () => void;
145
156
  editing: boolean;
146
- saving: boolean;
147
157
  taskSaving: boolean;
148
- onToggle: () => void;
149
- onSave: () => void;
158
+ onEdit: () => void;
150
159
  replaying: boolean;
151
160
  onHistory: (() => void) | null;
152
161
  }) {
153
162
  return (
154
163
  <header class="weave-note-head">
164
+ <p class="weave-note-crumb">
165
+ <Icon name="folder" class="weave-icon" />
166
+ {view.folder}
167
+ </p>
155
168
  <h3 class="weave-note-title">{view.title}</h3>
156
- <p class="weave-note-meta">
157
- {editing ? (
158
- <>
159
- <button type="button" class="weave-note-save" disabled={saving || taskSaving} onClick={onSave}>
160
- {saving ? SAVING_LABEL : SAVE_LABEL}
169
+ {editing ? null : (
170
+ <p class="weave-note-meta">
171
+ <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
172
+ <span class="weave-prov-dot" aria-hidden="true" />
173
+ {view.byline}
174
+ </span>
175
+ <span class="weave-note-time" title={`${CREATED_WORD} ${view.created} · ${view.createdIso}`}>
176
+ {EDITED_WORD} {view.updated}
177
+ </span>
178
+ <span class="weave-note-actions">
179
+ {onHistory === null ? null : (
180
+ <button type="button" class="weave-note-action weave-note-history" aria-pressed={replaying} onClick={onHistory}>
181
+ <Icon name="history" class="weave-icon" />
182
+ {replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
183
+ </button>
184
+ )}
185
+ {replaying ? null : (
186
+ <button type="button" class="weave-note-action weave-note-edit" disabled={taskSaving} onClick={onEdit}>
187
+ <Icon name="edit" class="weave-icon" />
188
+ {EDIT_LABEL}
189
+ </button>
190
+ )}
191
+ <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
192
+ <span class="weave-note-open-mark" aria-hidden="true">↗</span>
161
193
  </button>
162
- <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
163
- </>
164
- ) : replaying ? null : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
165
- {editing || onHistory === null ? null : (
166
- <button type="button" class="weave-note-edit weave-note-history" aria-pressed={replaying} onClick={onHistory}>
167
- {replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
168
- </button>
169
- )}
170
- <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
171
- <span class="weave-note-open-mark" aria-hidden="true">↗</span>
172
- </button>
173
- <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
174
- {view.provenanceGlyph} {view.provenance}
175
- </span>
176
- <span class="weave-note-time" title={view.updatedIso}>
177
- {EDITED_WORD} {view.updated}
178
- </span>
179
- <span class="weave-note-time" title={view.createdIso}>
180
- {CREATED_WORD} {view.created}
181
- </span>
182
- </p>
183
- <p class="weave-note-tags">
184
- {view.tags.map((tag) => (
185
- <span key={tag} class="weave-tag">
186
- {tagLabel(tag)}
187
194
  </span>
188
- ))}
189
- </p>
195
+ </p>
196
+ )}
197
+ {view.tags.length === 0 ? null : (
198
+ <p class="weave-note-tags">
199
+ {view.tags.map((tag) => (
200
+ <span key={tag} class="weave-tag">
201
+ {tagLabel(tag)}
202
+ </span>
203
+ ))}
204
+ </p>
205
+ )}
190
206
  </header>
191
207
  );
192
208
  }
193
209
 
210
+ /**
211
+ * The edit bar (E1): says the note is being edited, carries the formatting
212
+ * buttons, shows whether the words are on disk yet, and ends the session.
213
+ * Saving is automatic, so there is no Save button: Done catches up and closes.
214
+ */
215
+ function EditBar({
216
+ status,
217
+ busy,
218
+ onFormat,
219
+ onRetry,
220
+ onDiscard,
221
+ onDone,
222
+ }: {
223
+ status: EditStatus;
224
+ busy: boolean;
225
+ onFormat: (kind: FormatKind) => void;
226
+ onRetry: () => void;
227
+ onDiscard: () => void;
228
+ onDone: () => void;
229
+ }) {
230
+ return (
231
+ <div class="weave-editbar" role="toolbar" aria-label={EDITING_LABEL}>
232
+ <span class="weave-editbar-badge">
233
+ <Icon name="edit" class="weave-icon" />
234
+ {EDITING_LABEL}
235
+ </span>
236
+ <span class="weave-editbar-tools">
237
+ {FORMAT_TOOLS.map((tool) => (
238
+ <button
239
+ key={tool.kind}
240
+ type="button"
241
+ class="weave-editbar-tool"
242
+ title={tool.key === null ? tool.label : `${tool.label} (⌘${tool.key.toUpperCase()})`}
243
+ aria-label={tool.label}
244
+ // Keep the cursor and selection in the text box the button acts on.
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ onClick={() => onFormat(tool.kind)}
247
+ >
248
+ <Icon name={tool.icon} class="weave-icon" />
249
+ </button>
250
+ ))}
251
+ </span>
252
+ <span class={`weave-editbar-status weave-editbar-${status.kind}`} role="status">
253
+ {status.kind === "saved" ? <Icon name="check" class="weave-icon" /> : null}
254
+ {status.label}
255
+ {status.kind === "failed" ? (
256
+ <button type="button" class="weave-editbar-retry" onClick={onRetry}>{RETRY_LABEL}</button>
257
+ ) : null}
258
+ </span>
259
+ <button type="button" class="weave-editbar-discard" disabled={busy || status.kind === "saving"} onClick={onDiscard}>{DISCARD_EDITS_LABEL}</button>
260
+ <button type="button" class="weave-editbar-done" disabled={busy} onClick={onDone}>{DONE_LABEL}</button>
261
+ </div>
262
+ );
263
+ }
264
+
194
265
  export function Note(props: NoteProps) {
195
266
  const localDrafts = useRef(createDraftStore());
196
267
  const drafts = props.drafts ?? localDrafts.current;
@@ -234,8 +305,9 @@ export function Note(props: NoteProps) {
234
305
  // otherwise outgrow the note it belongs to.
235
306
  const [rawOpen, setRawOpen] = useState(false);
236
307
  const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
237
- const save = async (): Promise<void> => {
238
- if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
308
+ /** Write the draft and keep editing: autosave, ⌘S and Retry. */
309
+ const saveNow = (): void => {
310
+ if (note !== null && !drafts.isTaskSaving(note.slug)) void drafts.save(note.slug, props.onSave, { keepOpen: true });
239
311
  };
240
312
 
241
313
  const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
@@ -277,25 +349,89 @@ export function Note(props: NoteProps) {
277
349
  }
278
350
  }, [note?.slug, props.defaultEdit]);
279
351
 
280
- const toggleEdit = (): void => {
281
- if (note === null || drafts.isTaskSaving(note.slug)) return;
282
- if (drafts.get(note.slug) === null) {
283
- focusEditor.current = true;
284
- drafts.open(note.slug, body);
352
+ const startEdit = (): void => {
353
+ if (note === null || drafts.isTaskSaving(note.slug) || drafts.get(note.slug) !== null) return;
354
+ focusEditor.current = true;
355
+ drafts.open(note.slug, body);
356
+ };
357
+ // Done while an autosave is in flight waits for it, then closes.
358
+ const closeWhenSaved = useRef(false);
359
+ /** Catch up and close. A failed write leaves the editor open, saying so. */
360
+ const done = (): void => {
361
+ if (note === null || draft === null || drafts.isTaskSaving(note.slug)) return;
362
+ if (draft.saving) {
363
+ closeWhenSaved.current = true;
364
+ return;
365
+ }
366
+ closeWhenSaved.current = false;
367
+ if (drafts.isDirty(note.slug)) void drafts.save(note.slug, props.onSave);
368
+ else drafts.discard(note.slug);
369
+ };
370
+ /**
371
+ * Back to the note as it was when editing began. Before any autosave that
372
+ * is dropping the draft; after one, it writes the opening version back.
373
+ */
374
+ const discardEdits = (): void => {
375
+ if (note === null || draft === null || draft.saving) return;
376
+ closeWhenSaved.current = false;
377
+ const autosaved = draft.baseline !== draft.original || draft.rawBaseline !== draft.originalRaw;
378
+ if (autosaved) {
379
+ if (!window.confirm(REVERT_PROMPT)) return;
380
+ if (drafts.revert(note.slug)) void drafts.save(note.slug, props.onSave);
381
+ else drafts.discard(note.slug);
285
382
  } else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
286
383
  };
384
+ // The box the last formatting button or key acted on: the body, or Raw.
385
+ const rawEditorRef = useRef<HTMLTextAreaElement | null>(null);
386
+ const lastBox = useRef<"body" | "raw">("body");
387
+ const format = (kind: FormatKind): void => {
388
+ if (note === null) return;
389
+ const raw = lastBox.current === "raw" && rawEditorRef.current !== null;
390
+ const box = raw ? rawEditorRef.current : editorRef.current;
391
+ if (box === null) return;
392
+ const next = applyFormat({ value: box.value, start: box.selectionStart, end: box.selectionEnd }, kind);
393
+ // Insert only the changed span, through the browser, so ⌘Z undoes it.
394
+ // Where that is not supported, set the value (and lose undo) instead.
395
+ const span = replacedSpan(box.value, next.value);
396
+ box.focus();
397
+ box.setSelectionRange(span.start, span.end);
398
+ const inserted = typeof document.execCommand === "function"
399
+ && document.execCommand(span.text === "" ? "delete" : "insertText", false, span.text);
400
+ if (!inserted || box.value !== next.value) box.value = next.value;
401
+ if (raw) drafts.editRaw(note.slug, next.value);
402
+ else drafts.edit(note.slug, next.value);
403
+ // The selection is set now so typing replaces the placeholder the button inserted.
404
+ box.setSelectionRange(next.start, next.end);
405
+ };
287
406
  // Both editor boxes: bound here, not in `keys.model.ts`, because a global
288
407
  // binding is a workspace-wide claim and Escape must not reach the shell.
289
408
  const editorKeys = (event: KeyboardEvent): void => {
290
409
  if (event.key === "Escape") {
291
410
  event.stopPropagation();
292
- toggleEdit();
411
+ done();
412
+ return;
413
+ }
414
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
415
+ if (event.key.toLowerCase() === "s") {
416
+ event.preventDefault();
417
+ if (note !== null && drafts.isDirty(note.slug)) saveNow();
293
418
  return;
294
419
  }
295
- if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
420
+ const kind = formatForKey(event.key);
421
+ if (kind === null || event.shiftKey) return;
296
422
  event.preventDefault();
297
- void save();
423
+ format(kind);
298
424
  };
425
+ // Autosave: a pause in typing writes the draft. A failed write waits for
426
+ // new words or Retry rather than hammering a server that just said no.
427
+ useEffect(() => {
428
+ if (note === null || draft === null || draft.saving || draft.failed || !drafts.isDirty(note.slug)) return;
429
+ const timer = window.setTimeout(saveNow, AUTOSAVE_MS);
430
+ return () => window.clearTimeout(timer);
431
+ }, [note?.slug, draft, draft?.body, draft?.raw, draft?.saving, draft?.failed]);
432
+ useEffect(() => {
433
+ if (closeWhenSaved.current && draft !== null && !draft.saving) done();
434
+ }, [draft?.saving]);
299
435
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
300
436
 
301
437
  // Cursor in the textarea, after the last word, the moment it appears.
@@ -341,7 +477,9 @@ export function Note(props: NoteProps) {
341
477
  }, [note === null ? null : note.slug]);
342
478
  useLayoutEffect(() => {
343
479
  if (replayHtml === null || bodyRef.current === null) return;
344
- wrapMarks(bodyRef.current)?.scrollIntoView?.({ block: "nearest" });
480
+ const first = wrapMarks(bodyRef.current);
481
+ // A Raw append is marked but not followed: see `ReplayStep.follow`.
482
+ if (step?.follow) first?.scrollIntoView?.({ block: "nearest" });
345
483
  }, [replayHtml]);
346
484
  useEffect(() => {
347
485
  if (replay === null || !replay.playing || replay.revisions === null) return;
@@ -357,7 +495,10 @@ export function Note(props: NoteProps) {
357
495
  }, [replay]);
358
496
 
359
497
  useLayoutEffect(() => {
360
- if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
498
+ if (note === null) return;
499
+ drafts.taskBody(note.slug, note.body, props.loadVersion);
500
+ // Each load is the note as it is on disk now, after an autosave included.
501
+ drafts.syncTail(note.slug, note.body);
361
502
  }, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
362
503
 
363
504
  // A card whose target note left the screen is a claim about a document that
@@ -446,20 +587,29 @@ export function Note(props: NoteProps) {
446
587
  const header = noteHeader(note, props.now);
447
588
  return (
448
589
  <article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
590
+ {draft === null ? null : (
591
+ <EditBar
592
+ status={editStatus({ saving: draft.saving, failed: draft.failed, dirty: drafts.isDirty(note.slug), savedAt: draft.savedAt })}
593
+ busy={taskSaving}
594
+ onFormat={format}
595
+ onRetry={saveNow}
596
+ onDiscard={discardEdits}
597
+ onDone={done}
598
+ />
599
+ )}
449
600
  <Header
450
601
  view={header}
451
602
  open={() => props.onOpen(note.slug)}
452
603
  editing={draft !== null}
453
- saving={draft?.saving ?? false}
454
604
  taskSaving={taskSaving}
455
- onToggle={toggleEdit}
456
- onSave={() => void save()}
605
+ onEdit={startEdit}
457
606
  replaying={replay !== null}
458
607
  onHistory={loadHistory === undefined ? null : toggleHistory}
459
608
  />
460
609
  {replay === null ? null : (
461
610
  <ReplayBar
462
611
  state={replay}
612
+ now={props.now}
463
613
  onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
464
614
  onPlay={() => setReplay((state) => {
465
615
  if (state === null || state.revisions === null) return state;
@@ -480,6 +630,7 @@ export function Note(props: NoteProps) {
480
630
  spellcheck={props.spellcheck ?? true}
481
631
  value={draft.body}
482
632
  onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
633
+ onFocus={() => { lastBox.current = "body"; }}
483
634
  onKeyDown={editorKeys}
484
635
  />
485
636
  {/* A box of its own, never the body's textarea: text typed below
@@ -508,7 +659,9 @@ export function Note(props: NoteProps) {
508
659
  aria-label={RAW_EDITOR_ARIA_LABEL}
509
660
  spellcheck={props.spellcheck ?? true}
510
661
  value={draft.raw}
662
+ ref={rawEditorRef}
511
663
  onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
664
+ onFocus={() => { lastBox.current = "raw"; }}
512
665
  onKeyDown={editorKeys}
513
666
  />
514
667
  ) : null}
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { useLayoutEffect, useRef } from "preact/hooks";
8
8
  import type { HistoryRevision } from "../../shared/wire";
9
- import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, replayStep, splitMarked } from "./replay.model";
9
+ import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, changeBars, dayMarks, replayStep, splitMarked } from "./replay.model";
10
10
 
11
11
  /** What the column holds while replaying. `null` revisions means still loading. */
12
12
  export interface ReplayState {
@@ -52,8 +52,8 @@ export function wrapMarks(root: HTMLElement): HTMLElement | null {
52
52
  return first;
53
53
  }
54
54
 
55
- /** Past this many versions the track shows no per-version dots: they would touch. */
56
- const MAX_TICKS = 80;
55
+ /** Past this many versions the band shows no per-version bars: they would be hairlines. */
56
+ const MAX_BARS = 160;
57
57
 
58
58
  const Icon = (props: { d: string; fill?: boolean }) => (
59
59
  <svg width="14" height="14" viewBox="0 0 18 18" aria-hidden="true"
@@ -65,6 +65,8 @@ const Icon = (props: { d: string; fill?: boolean }) => (
65
65
 
66
66
  export function ReplayBar(props: {
67
67
  state: ReplayState;
68
+ /** Epoch ms, for the "Today" / "Yesterday" day labels. */
69
+ now: number;
68
70
  onIndex: (index: number) => void;
69
71
  onPlay: () => void;
70
72
  }) {
@@ -101,10 +103,19 @@ export function ReplayBar(props: {
101
103
  <Icon d="m7 4 5 5-5 5" />
102
104
  </button>
103
105
  <div class="weave-replay-track" ref={track}>
104
- {revisions.length > MAX_TICKS || last === 0 ? null : (
105
- <span class="weave-replay-ticks" aria-hidden="true">
106
- {revisions.map((revision, at) => (
107
- <span key={at} class={`weave-replay-tick weave-replay-${revision.by}${at > index ? " weave-replay-ahead" : ""}`} />
106
+ {revisions.length > MAX_BARS || last === 0 ? null : (
107
+ <span class="weave-replay-bars" aria-hidden="true" style={{ "--weave-bars": `${revisions.length}` }}>
108
+ {changeBars(revisions).map((bar, at) => (
109
+ // Placed where the slider's thumb sits for this version, so a bar and its stop line up.
110
+ <span key={at} style={{ "--weave-bar": `${Math.round(bar.height * 100)}%`, "--weave-bar-at": `${(at / last) * 100}%` }}
111
+ class={`weave-replay-bar weave-replay-${bar.by}${at > index ? " weave-replay-ahead" : ""}${at === index ? " weave-replay-current" : ""}`} />
112
+ ))}
113
+ </span>
114
+ )}
115
+ {last === 0 ? null : (
116
+ <span class="weave-replay-days" aria-hidden="true">
117
+ {dayMarks(revisions, props.now).map((mark) => (
118
+ <span key={mark.at} class="weave-replay-day" style={{ "--weave-day-at": `${mark.at * 100}%` }}>{mark.label}</span>
108
119
  ))}
109
120
  </span>
110
121
  )}
@@ -15,11 +15,29 @@ export interface NoteDraft {
15
15
  raw: string;
16
16
  rawBaseline: string;
17
17
  saving: boolean;
18
+ /** The body and Raw entries as they were when the editor opened: what Discard returns to. */
19
+ original: string;
20
+ originalRaw: string;
21
+ /** When an autosave last landed (epoch ms); null until one has. */
22
+ savedAt: number | null;
23
+ /** The last save attempt failed; cleared by the next one that lands. */
24
+ failed: boolean;
18
25
  }
19
26
 
20
27
  /** How a save should treat the `## Raw` tail; `raw` means the person edited it. */
21
28
  export interface SaveOptions {
22
29
  raw?: boolean;
30
+ /** An autosave: the shell reports a failure in the edit bar, not with an alert. */
31
+ quiet?: boolean;
32
+ }
33
+
34
+ /** How a draft save behaves. */
35
+ export interface DraftSaveOptions {
36
+ /**
37
+ * Keep the editor open after the write lands (autosave). Without it a save
38
+ * that catches up with the editor closes it, which is what Done does.
39
+ */
40
+ keepOpen?: boolean;
23
41
  }
24
42
 
25
43
  export type SaveNote = (slug: string, body: string, options?: SaveOptions) => Promise<boolean>;
@@ -39,10 +57,23 @@ export interface DraftStore {
39
57
  edit(slug: string, body: string): void;
40
58
  /** Edit the `## Raw` entries. */
41
59
  editRaw(slug: string, raw: string): void;
60
+ /**
61
+ * Catch an open editor's Raw box up with the note on disk. Entries an
62
+ * agent appended while the editor was open (and survived an autosave,
63
+ * which keeps the server's tail) would otherwise be missing from it, and
64
+ * the next save of an edited Raw would write them away.
65
+ */
66
+ syncTail(slug: string, serverBody: string): void;
42
67
  discard(slug: string): void;
43
68
  isDirty(slug: string): boolean;
44
69
  dirtySlugs(): string[];
45
- save(slug: string, onSave: SaveNote): Promise<void>;
70
+ save(slug: string, onSave: SaveNote, options?: DraftSaveOptions): Promise<void>;
71
+ /**
72
+ * Put the body and Raw entries back to how they were when the editor
73
+ * opened. Returns whether that differs from what is on disk now (an
74
+ * autosave already wrote the edits), in which case the caller saves.
75
+ */
76
+ revert(slug: string): boolean;
46
77
  nextLoadVersion(): number;
47
78
  getTaskBody(slug: string, fallback: string): string;
48
79
  taskBody(slug: string, serverBody: string, loadVersion: number): string;
@@ -51,7 +82,8 @@ export interface DraftStore {
51
82
  clearTaskSources(slugs: readonly string[]): void;
52
83
  }
53
84
 
54
- export function createDraftStore(): DraftStore {
85
+ /** `now` is the clock behind "Saved 12:04"; tests pass a fixed one. */
86
+ export function createDraftStore(now: () => number = Date.now): DraftStore {
55
87
  const drafts = new Map<string, NoteDraft>();
56
88
  const taskSources = new Map<string, TaskSource>();
57
89
  const listeners = new Set<() => void>();
@@ -69,19 +101,39 @@ export function createDraftStore(): DraftStore {
69
101
  if (drafts.has(slug)) return;
70
102
  const { head, tail } = splitRawTail(body);
71
103
  const { entries } = splitRawOpening(tail);
72
- drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false });
104
+ drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false, original: head, originalRaw: entries, savedAt: null, failed: false });
73
105
  notify();
74
106
  },
75
107
  edit(slug, body) {
76
108
  const draft = drafts.get(slug);
77
109
  if (draft === undefined || draft.body === body) return;
78
110
  draft.body = body;
111
+ // New words get a fresh autosave attempt; a failure says so until then.
112
+ draft.failed = false;
79
113
  notify();
80
114
  },
81
115
  editRaw(slug, raw) {
82
116
  const draft = drafts.get(slug);
83
117
  if (draft === undefined || draft.raw === raw) return;
84
118
  draft.raw = raw;
119
+ draft.failed = false;
120
+ notify();
121
+ },
122
+ syncTail(slug, serverBody) {
123
+ const draft = drafts.get(slug);
124
+ // A write in flight answers for itself, and Raw edits not yet saved are
125
+ // the person's to keep: only an untouched box follows the disk.
126
+ if (draft === undefined || draft.saving || draft.raw !== draft.rawBaseline) return;
127
+ const { tail } = splitRawTail(serverBody);
128
+ if (tail === draft.tail) return;
129
+ const { entries } = splitRawOpening(tail);
130
+ // Discard returns to how the note was when the editor opened, plus what
131
+ // was appended since: an agent's entry is not the person's edit to undo.
132
+ if (draft.originalRaw === draft.rawBaseline) draft.originalRaw = entries;
133
+ else if (entries.startsWith(draft.rawBaseline)) draft.originalRaw += entries.slice(draft.rawBaseline.length);
134
+ draft.tail = tail;
135
+ draft.raw = entries;
136
+ draft.rawBaseline = entries;
85
137
  notify();
86
138
  },
87
139
  discard(slug) {
@@ -94,7 +146,15 @@ export function createDraftStore(): DraftStore {
94
146
  dirtySlugs() {
95
147
  return [...drafts].filter(([, draft]) => dirty(draft)).map(([slug]) => slug);
96
148
  },
97
- async save(slug, onSave) {
149
+ revert(slug) {
150
+ const draft = drafts.get(slug);
151
+ if (draft === undefined) return false;
152
+ draft.body = draft.original;
153
+ draft.raw = draft.originalRaw;
154
+ notify();
155
+ return dirty(draft);
156
+ },
157
+ async save(slug, onSave, options) {
98
158
  const draft = drafts.get(slug);
99
159
  if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
100
160
  const sent = draft.body;
@@ -109,7 +169,8 @@ export function createDraftStore(): DraftStore {
109
169
  notify();
110
170
  let ok = false;
111
171
  try {
112
- ok = await onSave(slug, full, rawEdited ? { raw: true } : undefined);
172
+ const saveOptions: SaveOptions = { ...(rawEdited ? { raw: true } : {}), ...(options?.keepOpen ? { quiet: true } : {}) };
173
+ ok = await onSave(slug, full, Object.keys(saveOptions).length === 0 ? undefined : saveOptions);
113
174
  } finally {
114
175
  // Discard/reopen during an in-flight request gives the new draft a new identity.
115
176
  if (drafts.get(slug) === draft) {
@@ -119,13 +180,15 @@ export function createDraftStore(): DraftStore {
119
180
  source.version = ++loadVersion;
120
181
  delete source.pending;
121
182
  }
122
- if (ok && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
183
+ if (ok && !options?.keepOpen && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
123
184
  else {
124
185
  if (ok) {
125
186
  draft.baseline = sent;
126
187
  draft.rawBaseline = sentRaw;
127
188
  draft.tail = tail;
189
+ draft.savedAt = now();
128
190
  }
191
+ draft.failed = !ok;
129
192
  draft.saving = false;
130
193
  }
131
194
  notify();