@lapis-notes/api 0.1.3 → 0.1.4

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,8 +1,9 @@
1
- import { Annotation, EditorState, Transaction, StateField, EditorSelection as CodeMirrorSelection, } from "@codemirror/state";
1
+ import { Annotation, Compartment, EditorState, Transaction, StateField, EditorSelection as CodeMirrorSelection, } from "@codemirror/state";
2
2
  import { EditorView } from "@codemirror/view";
3
3
  import { redo as redoCommand, undo as undoCommand } from "@codemirror/commands";
4
4
  import { EventDispatcher } from "./events";
5
- import debounce from "lodash-es/debounce";
5
+ import { tick } from "svelte";
6
+ import { EditorSaveContext, } from "./editor-save-context";
6
7
  import { resolveApplication } from "./application-compatibility";
7
8
  export const editorViewField = StateField.define({
8
9
  create() {
@@ -59,6 +60,12 @@ export function createEditorState(editor, doc, extensions = [], selection) {
59
60
  return editor.file;
60
61
  },
61
62
  })),
63
+ editor.protectionExtensions(),
64
+ EditorState.transactionFilter.of((transaction) => transaction.docChanged &&
65
+ editor.editingProtected &&
66
+ !transaction.annotation(syncAnnotation)
67
+ ? []
68
+ : transaction),
62
69
  EditorView.updateListener.of((v) => {
63
70
  if (v.docChanged &&
64
71
  !v.transactions.some((it) => it.annotation(syncAnnotation))) {
@@ -97,21 +104,304 @@ export class Editor extends EventDispatcher {
97
104
  data;
98
105
  extensions;
99
106
  view;
100
- file = $state(null);
101
- persistence = "vault";
107
+ #file = $state.raw(null);
108
+ #persistence = "vault";
109
+ #externalWriter;
110
+ #editingProtected = $state(0);
111
+ #protection = new Compartment();
112
+ #documents = new Set();
113
+ #document;
102
114
  id = crypto.randomUUID();
103
115
  destroyed = false;
104
116
  application;
105
- pendingChange = debounce(async (content) => {
106
- if (this.data === content) {
117
+ #writeTail = Promise.resolve();
118
+ #sequence = 0;
119
+ #completedSequence = 0;
120
+ #documentGeneration = 0;
121
+ #fileReadGeneration = 0;
122
+ #refreshError;
123
+ #loading;
124
+ #controlledLoad = false;
125
+ get file() {
126
+ return this.#file;
127
+ }
128
+ set file(file) {
129
+ if (file === this.#file && file?.path === this.#document?.path)
107
130
  return;
131
+ this.#assertWritable();
132
+ const next = this.#createDocument(this.app, file, this.#persistence, this.#externalWriter);
133
+ this.#retireDocument();
134
+ this.#file = file;
135
+ this.#document = next;
136
+ this.#documentGeneration++;
137
+ }
138
+ get persistence() {
139
+ return this.#persistence;
140
+ }
141
+ set persistence(value) {
142
+ if (value === this.#persistence)
143
+ return;
144
+ this.#assertWritable();
145
+ const next = this.#createDocument(this.app, this.#file, value, this.#externalWriter);
146
+ this.#retireDocument();
147
+ this.#persistence = value;
148
+ this.#document = next;
149
+ this.#documentGeneration++;
150
+ }
151
+ /** Capture an explicit asynchronous owner for external-mode persistence. */
152
+ setExternalPersistenceWriter(writer) {
153
+ if (writer === this.#externalWriter)
154
+ return this;
155
+ this.#assertWritable();
156
+ const next = this.#createDocument(this.app, this.#file, this.#persistence, writer);
157
+ this.#retireDocument();
158
+ this.#externalWriter = writer;
159
+ this.#document = next;
160
+ this.#documentGeneration++;
161
+ return this;
162
+ }
163
+ #assertWritable() {
164
+ if (this.destroyed)
165
+ throw new Error("Editor is destroyed");
166
+ if (this.editingProtected)
167
+ throw new Error("Editor editing is protected");
168
+ }
169
+ #retireDocument() {
170
+ this.#document?.saves.retire();
171
+ }
172
+ #createDocument(application, file, persistence, writer) {
173
+ const document = {
174
+ application,
175
+ file,
176
+ path: file?.path,
177
+ persistence,
178
+ writer,
179
+ };
180
+ document.saves = new EditorSaveContext({
181
+ unownedExternal: persistence === "external" && !writer,
182
+ serialize: (run) => {
183
+ const result = this.#writeTail.then(run);
184
+ this.#writeTail = result.catch(() => undefined);
185
+ return result;
186
+ },
187
+ persist: async (snapshot) => {
188
+ if (file?.path !== document.path)
189
+ throw new Error("Editor persistence target changed");
190
+ if (persistence === "vault" && file)
191
+ await application.vault.modify(file, snapshot.content);
192
+ else if (persistence === "external" && writer)
193
+ await writer({ application, file, content: snapshot.content });
194
+ },
195
+ saved: (snapshot) => {
196
+ this.#completedSequence = Math.max(this.#completedSequence, snapshot.sequence);
197
+ if (!this.destroyed &&
198
+ this.#document === document &&
199
+ this.app === application &&
200
+ this.file === file &&
201
+ snapshot.documentGeneration === this.#documentGeneration) {
202
+ this.data = snapshot.content;
203
+ if (snapshot.content === this.getValue())
204
+ this.onChange(snapshot.content);
205
+ }
206
+ },
207
+ released: () => {
208
+ document.unregister?.();
209
+ this.#documents.delete(document);
210
+ },
211
+ });
212
+ if (file) {
213
+ if (!application.fileEditingSessions)
214
+ throw new Error("App has no file editing-session registry");
215
+ document.unregister = application.fileEditingSessions.register(file.path, {
216
+ get dirty() {
217
+ return !!document.loading || document.saves.dirty;
218
+ },
219
+ flush: async () => {
220
+ await document.loading;
221
+ await document.saves.flush();
222
+ },
223
+ protect: () => this.#protectDocument(document),
224
+ });
108
225
  }
109
- if (this.file && this.persistence === "vault") {
110
- await this.app.vault.modify(this.file, content);
226
+ this.#documents.add(document);
227
+ return document;
228
+ }
229
+ #currentDocument() {
230
+ return (this.#document ??= this.#createDocument(this.app, this.file, this.persistence, this.#externalWriter));
231
+ }
232
+ captureChange(content) {
233
+ return {
234
+ content,
235
+ documentGeneration: this.#documentGeneration,
236
+ sequence: ++this.#sequence,
237
+ };
238
+ }
239
+ get hasUnsavedChanges() {
240
+ return ((!this.destroyed && this.getValue() !== this.data) ||
241
+ [...this.#documents].some((document) => document.saves.dirty));
242
+ }
243
+ get editingProtected() {
244
+ return this.destroyed || this.#editingProtected > 0;
245
+ }
246
+ /** Native CodeMirror protection, retained whenever editor state is rebuilt. */
247
+ protectionExtensions() {
248
+ return this.#protection.of([
249
+ EditorState.readOnly.of(this.editingProtected),
250
+ EditorView.editable.of(!this.editingProtected),
251
+ ]);
252
+ }
253
+ #holdEditing() {
254
+ this.#editingProtected++;
255
+ this.#updateProtection();
256
+ let held = true;
257
+ return () => {
258
+ if (!held)
259
+ return;
260
+ held = false;
261
+ this.#editingProtected--;
262
+ this.#updateProtection();
263
+ };
264
+ }
265
+ #updateProtection() {
266
+ if (!this.destroyed)
267
+ this.view.dispatch({
268
+ effects: this.#protection.reconfigure([
269
+ EditorState.readOnly.of(this.editingProtected),
270
+ EditorView.editable.of(!this.editingProtected),
271
+ ]),
272
+ });
273
+ }
274
+ async #protectDocument(document) {
275
+ if (document.persistence === "external" && !document.writer)
276
+ throw new Error("External editor persistence requires an explicit writer for protection");
277
+ const release = !this.destroyed && (this.#document === document || !!document.loading)
278
+ ? this.#holdEditing()
279
+ : () => undefined;
280
+ try {
281
+ await tick();
282
+ await document.loading;
283
+ await document.saves.flush();
284
+ return release;
111
285
  }
112
- this.data = content;
113
- this.onChange(content);
114
- }, 500);
286
+ catch (error) {
287
+ release();
288
+ throw error;
289
+ }
290
+ }
291
+ /** Protect the current document and drain its captured persistence. */
292
+ protect() {
293
+ if (this.destroyed)
294
+ return Promise.reject(new Error("Editor is destroyed"));
295
+ return this.#protectDocument(this.#currentDocument());
296
+ }
297
+ /** Register and protect a target, then publish its file and contents atomically. */
298
+ async loadDocument(file, read, publish) {
299
+ if (this.destroyed)
300
+ throw new Error("Editor is destroyed");
301
+ if (this.#editingProtected > (this.#loading ? 1 : 0))
302
+ throw new Error("Editor editing is protected");
303
+ const application = this.app, path = file.path;
304
+ // Admission failure leaves any previous load and binding untouched.
305
+ const document = this.#createDocument(application, file, this.persistence, this.#externalWriter);
306
+ const release = this.#holdEditing();
307
+ let cancelRead;
308
+ const cancellation = new Promise((resolve) => {
309
+ cancelRead = resolve;
310
+ });
311
+ let finish;
312
+ document.loading = new Promise((resolve) => {
313
+ finish = resolve;
314
+ });
315
+ let canceled = false, completed = false;
316
+ const load = {
317
+ cancel: () => {
318
+ if (completed || canceled)
319
+ return;
320
+ canceled = true;
321
+ cancelRead({ canceled: true });
322
+ document.loading = undefined;
323
+ document.saves.retire();
324
+ finish();
325
+ release();
326
+ if (this.#loading === load)
327
+ this.#loading = undefined;
328
+ },
329
+ };
330
+ this.#loading?.cancel();
331
+ this.#loading = load;
332
+ let published = false;
333
+ try {
334
+ const drained = await Promise.race([
335
+ this.flushChanges().then(() => ({ drained: true })),
336
+ cancellation,
337
+ ]);
338
+ if ("canceled" in drained || canceled)
339
+ return false;
340
+ const result = await Promise.race([
341
+ Promise.resolve()
342
+ .then(read)
343
+ .then((contents) => ({ contents })),
344
+ cancellation,
345
+ ]);
346
+ if ("canceled" in result || canceled)
347
+ return false;
348
+ if (this.destroyed || this.app !== application || file.path !== path)
349
+ throw new Error("Editor document load authority changed");
350
+ const previous = {
351
+ document: this.#document,
352
+ file: this.#file,
353
+ data: this.data,
354
+ state: this.view.state,
355
+ extensions: this.extensions,
356
+ };
357
+ this.#document = document;
358
+ this.#file = file;
359
+ try {
360
+ this.#controlledLoad = true;
361
+ this.setValue(result.contents);
362
+ publish?.(result.contents);
363
+ if (this.destroyed || canceled)
364
+ throw new Error("Editor document load canceled during publication");
365
+ if (this.app !== application || file.path !== path)
366
+ throw new Error("Editor document load authority changed");
367
+ published = true;
368
+ previous.document?.saves.retire();
369
+ }
370
+ catch (error) {
371
+ this.#document = previous.document;
372
+ this.#file = previous.file;
373
+ this.data = previous.data;
374
+ this.extensions = previous.extensions;
375
+ this.#documentGeneration++;
376
+ if (!this.destroyed)
377
+ this.view.setState(previous.state);
378
+ throw error;
379
+ }
380
+ finally {
381
+ this.#controlledLoad = false;
382
+ }
383
+ return true;
384
+ }
385
+ finally {
386
+ if (!canceled) {
387
+ completed = true;
388
+ document.loading = undefined;
389
+ if (!published)
390
+ document.saves.retire();
391
+ finish();
392
+ release();
393
+ }
394
+ if (this.#loading === load)
395
+ this.#loading = undefined;
396
+ }
397
+ }
398
+ /** Cancel a pending publication while observing any late read result. */
399
+ cancelDocumentLoad() {
400
+ this.#loading?.cancel();
401
+ }
402
+ get refreshError() {
403
+ return this.#refreshError;
404
+ }
115
405
  constructor(data = "", extensions = [], application) {
116
406
  super();
117
407
  this.data = data;
@@ -121,19 +411,30 @@ export class Editor extends EventDispatcher {
121
411
  }
122
412
  /** Bind this editor to the application that owns its view. */
123
413
  bindApplication(application) {
414
+ if (this.application === application)
415
+ return this;
416
+ this.#assertWritable();
417
+ const next = this.#createDocument(application, this.#file, this.#persistence, this.#externalWriter);
418
+ this.#retireDocument();
124
419
  this.application = application;
420
+ this.#document = next;
421
+ this.#documentGeneration++;
125
422
  return this;
126
423
  }
127
424
  /** The owning application, with the global alias retained as fallback. */
128
425
  get app() {
129
426
  return resolveApplication(this.application);
130
427
  }
131
- save = debounce(() => {
132
- if (this.file) {
133
- return this.app.vault.modify(this.file, this.getValue());
134
- }
135
- return Promise.resolve();
136
- }, 500);
428
+ save = Object.assign(() => {
429
+ this.#assertWritable();
430
+ return this.#currentDocument().saves.queue(this.captureChange(this.getValue()), true);
431
+ }, {
432
+ flush: () => this.#document?.saves.flushSave(),
433
+ cancel: () => {
434
+ this.#assertWritable();
435
+ this.#document?.saves.cancelSave();
436
+ },
437
+ });
137
438
  get cm() {
138
439
  return this.view;
139
440
  }
@@ -142,15 +443,38 @@ export class Editor extends EventDispatcher {
142
443
  }
143
444
  /** Queue the latest editor contents for persistence/change notification. */
144
445
  queueChange(data) {
145
- this.pendingChange(data);
146
- }
147
- /** Flush the latest queued change before a view or embedded surface closes. */
148
- flushChanges() {
149
- return Promise.resolve(this.pendingChange.flush()).then(() => undefined);
446
+ this.#assertWritable();
447
+ this.#currentDocument().saves.queue(this.captureChange(data));
448
+ }
449
+ /** Drain every captured document, including failed or retired saves. */
450
+ async flushChanges() {
451
+ const errors = new Map();
452
+ const visited = new Set();
453
+ let sequence, generation;
454
+ do {
455
+ sequence = this.#sequence;
456
+ generation = this.#documentGeneration;
457
+ const documents = [...this.#documents];
458
+ const results = await Promise.allSettled(documents.map((document) => {
459
+ const retry = !visited.has(document);
460
+ visited.add(document);
461
+ return document.saves.flush(retry);
462
+ }));
463
+ results.forEach((result, index) => {
464
+ if (result.status === "rejected")
465
+ errors.set(documents[index], result.reason);
466
+ });
467
+ } while (sequence !== this.#sequence ||
468
+ generation !== this.#documentGeneration);
469
+ if (errors.size === 1)
470
+ throw errors.values().next().value;
471
+ if (errors.size)
472
+ throw new AggregateError([...errors.values()], "Editor persistence failed");
150
473
  }
151
474
  /** Cancel a queued change when the owning document is intentionally reset. */
152
475
  cancelPendingChanges() {
153
- this.pendingChange.cancel();
476
+ this.#assertWritable();
477
+ this.#document?.saves.cancelChange();
154
478
  }
155
479
  getValue() {
156
480
  return this.view.state.doc.toString();
@@ -159,16 +483,25 @@ export class Editor extends EventDispatcher {
159
483
  this.view.requestMeasure();
160
484
  }
161
485
  destroy() {
162
- if (this.destroyed) {
486
+ if (this.destroyed)
163
487
  return;
164
- }
165
488
  this.destroyed = true;
166
- this.pendingChange.cancel();
167
- this.save.cancel();
489
+ this.cancelDocumentLoad();
490
+ for (const document of this.#documents)
491
+ document.saves.retire();
168
492
  this.view.destroy();
169
493
  }
494
+ /** Stop editing immediately and await all retained document persistence. */
495
+ async dispose() {
496
+ this.destroy();
497
+ await this.flushChanges();
498
+ }
170
499
  trackChanges(callback) {
171
- const editorUpdated = this.app.workspace.on("editor-updated", (editor, transactions) => {
500
+ const owner = this.app;
501
+ let active = true;
502
+ const editorUpdated = owner.workspace.on("editor-updated", (editor, transactions) => {
503
+ if (!active || this.destroyed || this.app !== owner)
504
+ return;
172
505
  if (editor.id !== this.id && editor.file?.path === this.file?.path) {
173
506
  if (this.getValue() !== editor.getValue()) {
174
507
  this.view.dispatch(...transactions);
@@ -177,22 +510,60 @@ export class Editor extends EventDispatcher {
177
510
  }
178
511
  }
179
512
  });
180
- const fileChanged = this.app.workspace.on("file-change", (file, event) => {
513
+ const fileChanged = owner.workspace.on("file-change", (file, event) => {
514
+ if (!active || this.destroyed || this.app !== owner)
515
+ return;
181
516
  if (file.path === this.file?.path) {
182
517
  if (event !== "delete" && file) {
183
- this.app.vault.read(file).then((contents) => {
518
+ const application = this.app, target = this.file, path = target?.path;
519
+ const readGeneration = ++this.#fileReadGeneration, documentGeneration = this.#documentGeneration;
520
+ const sequence = this.#sequence, completed = this.#completedSequence;
521
+ void application.vault
522
+ .read(file)
523
+ .then((contents) => {
524
+ if (!active ||
525
+ this.destroyed ||
526
+ this.app !== application ||
527
+ this.file !== target ||
528
+ this.file?.path !== path ||
529
+ this.#fileReadGeneration !== readGeneration ||
530
+ this.#documentGeneration !== documentGeneration ||
531
+ this.#sequence !== sequence ||
532
+ this.#completedSequence !== completed)
533
+ return;
534
+ this.#refreshError = undefined;
535
+ if (this.getValue() === contents) {
536
+ this.data = contents;
537
+ return;
538
+ }
539
+ if (this.getValue() !== this.data ||
540
+ this.#document?.saves.dirty)
541
+ return;
184
542
  if (this.getValue() !== contents) {
185
- this.replaceContent(contents);
543
+ this.#replaceContent(contents);
186
544
  this.onChange(this.getValue());
187
545
  callback?.(this.getValue(), this);
188
546
  }
547
+ })
548
+ .catch((error) => {
549
+ if (active &&
550
+ !this.destroyed &&
551
+ this.app === application &&
552
+ this.file?.path === path &&
553
+ this.#fileReadGeneration === readGeneration &&
554
+ this.file === target &&
555
+ this.#documentGeneration === documentGeneration &&
556
+ this.#sequence === sequence &&
557
+ this.#completedSequence === completed)
558
+ this.#refreshError = error;
189
559
  });
190
560
  }
191
561
  }
192
562
  });
193
563
  return () => {
194
- this.app.workspace.offref(editorUpdated);
195
- this.app.workspace.offref(fileChanged);
564
+ active = false;
565
+ owner.workspace.offref(editorUpdated);
566
+ owner.workspace.offref(fileChanged);
196
567
  };
197
568
  }
198
569
  updateExtensions(extensions, context) {
@@ -207,7 +578,6 @@ export class Editor extends EventDispatcher {
207
578
  const content = this.getValue();
208
579
  const selection = this.view.state.selection;
209
580
  this.extensions = editorExtensions;
210
- this.data = content;
211
581
  this.view.setState(createEditorState(this, content, this.extensions, selection));
212
582
  return this;
213
583
  }, {
@@ -216,6 +586,12 @@ export class Editor extends EventDispatcher {
216
586
  });
217
587
  }
218
588
  setValue(content) {
589
+ if (!this.#controlledLoad)
590
+ this.#assertWritable();
591
+ else if (this.destroyed)
592
+ throw new Error("Editor is destroyed");
593
+ this.#documentGeneration++;
594
+ this.#refreshError = undefined;
219
595
  this.app.telemetry.measure("editor.set_value", (span) => {
220
596
  span.setAttribute("editor.content_length", content.length);
221
597
  span.setAttribute("file.extension", this.file?.extension ?? "");
@@ -227,6 +603,12 @@ export class Editor extends EventDispatcher {
227
603
  });
228
604
  }
229
605
  replaceContent(content, options = {}) {
606
+ this.#assertWritable();
607
+ this.#replaceContent(content, options);
608
+ }
609
+ #replaceContent(content, options = {}) {
610
+ this.#documentGeneration++;
611
+ this.#refreshError = undefined;
230
612
  this.data = content;
231
613
  const annotations = [syncAnnotation.of(true)];
232
614
  this.view.dispatch({
@@ -0,0 +1,28 @@
1
+ /** Independent file writers implement this contract without importing a renderer. */
2
+ export interface FileEditingSession {
3
+ /** Includes queued, in-flight and failed persistence. */
4
+ readonly dirty: boolean;
5
+ flush(): Promise<void>;
6
+ /** Block edits before yielding; drain pending saves and return an idempotent release. */
7
+ protect(): Promise<() => void | Promise<void>>;
8
+ }
9
+ /**
10
+ * An App-owned persistence and protection boundary for independent file writers.
11
+ * Hosts must register every writer before selecting this as an apply guard.
12
+ */
13
+ export declare class FileEditingSessionRegistry {
14
+ #private;
15
+ register(path: string, session: FileEditingSession): () => void;
16
+ /** Flush the captured sessions; protection is required for mutation admission. */
17
+ flush(path?: string): Promise<{
18
+ flushed: boolean;
19
+ path?: string;
20
+ }>;
21
+ /**
22
+ * Serialize mutations, holding matching sessions protected throughout the callback.
23
+ * Callbacks and session methods must not re-enter queued registry operations.
24
+ */
25
+ guard<T>(path: string | undefined, operation: () => Promise<T>): Promise<T>;
26
+ /** Stop admission immediately, drain callbacks and retry failed persistence/cleanup. */
27
+ dispose(): Promise<void>;
28
+ }