Skip to main content

text_document/
streaming.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Append-only streaming: the log/console path.
5//!
6//! A view tailing output does two things in a loop — append a line at the end,
7//! and drop the oldest once it is over its scrollback cap. Both are expressible
8//! with the ordinary editing API, and both are O(N) that way: appending one line
9//! to a 10 000-line document costs ~15.9 ms, and evicting 20 lines ~55 ms
10//! (`docs/streaming-baseline.md`). Neither cost is inherent — it comes from
11//! general-purpose machinery that a tail-append does not need:
12//!
13//! * `character_count()` / `cursor_at()` each run `get_document_stats`, which
14//!   materializes every block's text to compute a word count the caller never
15//!   asked for — just to locate the end of the document.
16//! * `insert_block` fetches *every* block entity (via
17//!   `collect_block_ids_recursive` and `get_block_multi`) before it can find
18//!   the insertion point. `insert_text` has an O(log n) rope fast path;
19//!   `insert_block` never received one.
20//! * `delete_text` walks the whole `child_order` refreshing every block's stored
21//!   position, with a rope lookup per block, regardless of how little was
22//!   deleted.
23//!
24//! None of that is needed when the insertion point is, by construction, the end:
25//! the rope already knows where the end is, nothing after it needs shifting, and
26//! no block above it is touched. This module takes that shortcut directly, via
27//! the `rope_helpers` primitives (`rope_append_block` is a rope insert at the
28//! tail plus an O(1) amortized `push_block` — nothing shifts, because appending
29//! at the end shifts nothing).
30//!
31//! # Not undoable — and not an oversight
32//!
33//! **Nothing in this module participates in undo/redo.** An appended or evicted
34//! line cannot be brought back with [`TextDocument::undo`], does not appear on
35//! the undo stack, and does not affect [`TextDocument::can_undo`].
36//!
37//! That is deliberate on both counts. A million streamed lines must not become a
38//! million undo entries — the stack is unbounded, so that is a leak, not a
39//! feature. And the alternative escape hatch the rest of the crate uses for
40//! non-undoable work (perform, then `clear_stack`, as `set_plain_text` does)
41//! would throw away the *user's* history every time a line arrived from a
42//! background thread.
43//!
44//! So the entity writes are routed through a private stack of their own, cleared
45//! as they go. Note that passing `None` as the stack id would not have achieved
46//! this: `add_command_to_stack` resolves `None` to stack 0 rather than skipping,
47//! so it is not an opt-out.
48//!
49//! Consequences worth being deliberate about:
50//!
51//! * **The document's own undo stack is untouched.** A user's edits stay
52//!   undoable across any amount of streaming, and undo will never surface or
53//!   swallow a streamed line. This is covered by a test.
54//! * **Streaming is not reversible.** Content that arrived from elsewhere is not
55//!   the user's to undo; if a caller needs to drop it, that is
56//!   [`truncate_front`](TextDocument::truncate_front), not undo.
57//! * **`modified` is still set**, and change events are still emitted, so views
58//!   and dirty-tracking behave normally.
59//!
60//! Mixing streaming with ordinary editing on one document works and is tested
61//! (the rope and the block index stay coherent), but it is not the intended
62//! shape: these are for buffers whose content arrives from elsewhere.
63//!
64//! # All-or-nothing
65//!
66//! Every entry point here is atomic: it either lands completely or leaves the
67//! document exactly as it was. That needs saying because it does not come for
68//! free. These paths hand-maintain four things that must agree — the rope, the
69//! block offset index, the frame's `child_order`, and the document's cached
70//! counts — across several fallible calls, and the unit-of-work layer that
71//! normally guarantees that is precisely what they bypass for speed. Without
72//! care, any early `?` would leave text in the rope that no block owns, or a
73//! block the frame never references, or counts describing a document that no
74//! longer exists — none of which surfaces as an error, only as a document that
75//! quietly disagrees with itself.
76//!
77//! So each operation takes a savepoint first and rolls back on any failure,
78//! including a panic. A savepoint is a whole-store snapshot, but a cheap one:
79//! the rope is persistent and the entity tables are `im::HashMap`s, so it is
80//! pointer copies, not a deep clone.
81//!
82//! Note that a rolled-back append still consumes the entity ids it allocated —
83//! the store's counters are deliberately not rewound — so ids may show gaps.
84//! Nothing depends on them being contiguous.
85//!
86//! # This is still O(N) — know the ceiling before using it
87//!
88//! Measured, appending one line (`docs/streaming-baseline.md`):
89//!
90//! | lines in document | ordinary editing path | this |
91//! |---|---|---|
92//! | 1 000 | 986 µs | 174 µs |
93//! | 10 000 | 28.1 ms | 2.90 ms |
94//!
95//! Roughly 10× cheaper — but 174 µs → 2.90 ms is 16.7× for 10× the document, so
96//! **this is still O(N), not O(1)**. It moves the ceiling; it does not remove it.
97//! Around 10 000 lines an append costs ~2.9 ms (~345 lines/second), which suits
98//! an application's own event log. It does not suit `tail -f` of a large file:
99//! at 100 000 lines the cost extrapolates to ~29 ms per line. Cap the buffer
100//! accordingly, and evict with [`truncate_front`](TextDocument::truncate_front).
101//!
102//! `truncate_front` is barely better than the ordinary path (1.19× at 10 000)
103//! and is *slower* below a few thousand lines, because the ordinary path makes a
104//! single bulk `delete_text` call where this makes one entity removal per evicted
105//! block. It exists for the coherence of the streaming API — appending without
106//! being able to evict is useless — not because it is fast.
107//!
108//! The rope is not the reason. `rope_append_block` is O(log n) as intended; the
109//! residual is the generic entity-update path. `UndoableUpdateUseCase::
110//! execute_multi` fetches the old entity for undo and stashes it on every write,
111//! so each `update_frame` clones the `Frame` — its whole `child_order: Vec<i64>`
112//! included — several times over. Every appended line therefore touches the
113//! entire `child_order`, whatever the rope does.
114//!
115//! Reaching O(1) means `child_order` no longer being an N-element `Vec` cloned
116//! per write. That reshapes a core entity the whole engine and its property tests
117//! depend on, and is deliberately out of scope here.
118
119use std::sync::Arc;
120
121use frontend::block::dtos::CreateBlockDto;
122use frontend::commands::{block_commands, document_commands, frame_commands, undo_redo_commands};
123use frontend::common::types::EntityId;
124use frontend::document::dtos::UpdateDocumentDto;
125use frontend::frame::dtos::UpdateFrameDto;
126
127use crate::document::get_main_frame_id;
128use crate::events::DocumentEvent;
129use crate::inner::TextDocumentInner;
130use crate::{DocumentError, Result, TextDocument};
131
132impl TextDocument {
133    /// Append `text` as a new block at the end, returning the document's new
134    /// block count.
135    ///
136    /// The append half of a streaming buffer. Costs the rope insert and one
137    /// entity write, whatever the buffer already holds — against ~15.9 ms per
138    /// line at 10 000 lines through the ordinary editing path
139    /// (`docs/streaming-baseline.md`).
140    ///
141    /// The returned count is what a scrollback cap is checked against, so a
142    /// caller never needs a separate count call — which matters, because
143    /// [`block_count`](Self::block_count) walks the whole document:
144    ///
145    /// ```ignore
146    /// let count = doc.append_line(line)?;
147    /// if count > CAP {
148    ///     doc.truncate_front(count - CAP)?;
149    /// }
150    /// ```
151    ///
152    /// `text` is taken as a single line: it must not contain `\n`, since a block
153    /// is one line by construction here (embedded newlines would desynchronize
154    /// the rope from the block index). Returns [`DocumentError::InvalidArgument`]
155    /// if it does.
156    ///
157    /// **Not undoable**, by design — see the module docs.
158    pub fn append_line(&self, text: &str) -> Result<usize> {
159        if text.contains('\n') {
160            return Err(DocumentError::InvalidArgument(
161                "append_line takes a single line; text must not contain '\\n'".into(),
162            ));
163        }
164
165        let mut inner = self.inner.lock();
166        let frame_id = get_main_frame_id(&inner);
167        if frame_id == 0 {
168            return Err(DocumentError::InvalidArgument(
169                "document has no main frame".into(),
170            ));
171        }
172
173        // Everything below either all lands or none of it does.
174        let atomic = Atomic::begin(&inner);
175        let appended = append_one(&mut inner, frame_id, text)?;
176        let new_count = commit_counts(&mut inner, 1, text.chars().count() as i64)?;
177        atomic.commit();
178
179        finish(&mut inner, appended.edit_pos, appended.chars_added, 1);
180
181        inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
182        // A pure tail append: the new element lands at the end of the flow, so
183        // the index is the previous count. Emitted directly rather than through
184        // the generic `check_flow_changed`, which diffs the whole `child_order`
185        // on every edit — O(N) again, to rediscover what is known here.
186        inner.queue_event(DocumentEvent::FlowElementsInserted {
187            flow_index: new_count - 1,
188            count: 1,
189        });
190
191        // Notify `on_change` subscribers, like every other mutation — the poll
192        // path alone would leave callback-based observers (a reactive view) blind
193        // to appends. Dispatch outside the lock: a callback may re-enter.
194        let queued = inner.take_queued_events();
195        drop(inner);
196        crate::inner::dispatch_queued_events(queued);
197        Ok(new_count)
198    }
199
200    /// Append several lines in one go, returning the document's new block count.
201    ///
202    /// Equivalent to [`append_line`](Self::append_line) per line, but pays the
203    /// document-count write and the throwaway-stack clear once for the batch
204    /// rather than per line. A view draining a channel once per frame should
205    /// prefer this.
206    ///
207    /// No line may contain `\n`.
208    ///
209    /// **Not undoable**, by design — see the module docs.
210    pub fn append_lines<I, S>(&self, lines: I) -> Result<usize>
211    where
212        I: IntoIterator<Item = S>,
213        S: AsRef<str>,
214    {
215        let lines: Vec<String> = lines.into_iter().map(|s| s.as_ref().to_owned()).collect();
216        if lines.iter().any(|l| l.contains('\n')) {
217            return Err(DocumentError::InvalidArgument(
218                "append_lines takes single lines; none may contain '\\n'".into(),
219            ));
220        }
221        if lines.is_empty() {
222            let inner = self.inner.lock();
223            return Ok(current_block_count(&inner)? as usize);
224        }
225
226        let mut inner = self.inner.lock();
227        let frame_id = get_main_frame_id(&inner);
228        if frame_id == 0 {
229            return Err(DocumentError::InvalidArgument(
230                "document has no main frame".into(),
231            ));
232        }
233
234        // The batch is one unit: a line failing halfway would otherwise leave
235        // its predecessors appended with the document's counts never updated,
236        // since those are written once at the end.
237        let atomic = Atomic::begin(&inner);
238        let mut edit_pos = None;
239        let mut chars_added = 0usize;
240        for line in &lines {
241            let appended = append_one(&mut inner, frame_id, line)?;
242            // The batch's edit starts where its first line did.
243            edit_pos.get_or_insert(appended.edit_pos);
244            chars_added += appended.chars_added;
245        }
246        let edit_pos = edit_pos.unwrap_or(0);
247
248        let chars: i64 = lines.iter().map(|l| l.chars().count() as i64).sum();
249        let new_count = commit_counts(&mut inner, lines.len() as i64, chars)?;
250        atomic.commit();
251
252        finish(&mut inner, edit_pos, chars_added, lines.len());
253
254        inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
255        inner.queue_event(DocumentEvent::FlowElementsInserted {
256            flow_index: new_count - lines.len(),
257            count: lines.len(),
258        });
259
260        // Dispatch to `on_change` subscribers (outside the lock — a callback may
261        // re-enter), the same as an ordinary edit.
262        let queued = inner.take_queued_events();
263        drop(inner);
264        crate::inner::dispatch_queued_events(queued);
265        Ok(new_count)
266    }
267
268    /// Drop the first `n` blocks, returning how many were actually removed.
269    ///
270    /// The eviction half of a streaming buffer. Returns less than `n` when the
271    /// document holds fewer blocks; a document is never emptied completely —
272    /// one block always remains, since an empty document is not a valid state
273    /// here (it is created with one block, and the rest of the API assumes at
274    /// least one exists).
275    ///
276    /// **Not undoable**, by design — see the module docs.
277    pub fn truncate_front(&self, n: usize) -> Result<usize> {
278        if n == 0 {
279            return Ok(0);
280        }
281
282        let mut inner = self.inner.lock();
283        let frame_id = get_main_frame_id(&inner);
284        if frame_id == 0 {
285            return Err(DocumentError::InvalidArgument(
286                "document has no main frame".into(),
287            ));
288        }
289
290        let stack = streaming_stack(&mut inner);
291
292        // Which blocks to drop, capped so that at least one always survives.
293        let (victims, chars_removed) = {
294            let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
295                .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
296            let block_ids: Vec<EntityId> = frame
297                .child_order
298                .iter()
299                .filter(|e| **e > 0)
300                .map(|e| *e as EntityId)
301                .collect();
302            let take = n.min(block_ids.len().saturating_sub(1));
303            let victims: Vec<EntityId> = block_ids.into_iter().take(take).collect();
304
305            // Every victim's length must be counted, so a lookup that fails is
306            // an error rather than a silent zero: swallowing it would subtract
307            // too little from `character_count`, and since all later deltas are
308            // relative, the document's count would stay wrong forever with
309            // nothing reporting it.
310            let mut chars: i64 = 0;
311            for id in &victims {
312                let block = block_commands::get_block(&inner.ctx, id)?.ok_or_else(|| {
313                    DocumentError::InvalidArgument(format!(
314                        "block {id} is referenced by the frame but does not exist"
315                    ))
316                })?;
317                let entity: common::entities::Block = block.into();
318                let store = inner.ctx.db_context.get_store();
319                chars += common::database::rope_helpers::block_char_length(&entity, store);
320            }
321            (victims, chars)
322        };
323
324        if victims.is_empty() {
325            return Ok(0);
326        }
327
328        // Everything below either all lands or none of it does. Without this,
329        // a failure partway through the entity loop would leave the rope
330        // already stripped of victims whose blocks still exist and are still
331        // referenced by the frame.
332        let atomic = Atomic::begin(&inner);
333
334        // Unmirror from the rope first: `rope_remove_block` resolves each block
335        // through the offset index, which the entity must still exist for.
336        {
337            let store = inner.ctx.db_context.get_store();
338            for id in &victims {
339                common::database::rope_helpers::rope_remove_block(store, *id);
340            }
341        }
342
343        // Drop the entities, then the frame's references to them in one write.
344        for id in &victims {
345            block_commands::remove_block(&inner.ctx, Some(stack), id)?;
346        }
347        {
348            let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
349                .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
350            let mut update: UpdateFrameDto = frame.into();
351            // Drop exactly the evicted ids, rather than the first N entries:
352            // `remove_block` may already have pruned them, and `child_order`
353            // can hold negative entries (sub-frames) that are not blocks, so
354            // positional removal would take out survivors.
355            let evicted: std::collections::HashSet<i64> =
356                victims.iter().map(|id| *id as i64).collect();
357            let before = update.child_order.len();
358            update.child_order.retain(|e| !evicted.contains(e));
359            if update.child_order.len() != before {
360                frame_commands::update_frame(&inner.ctx, Some(stack), &update)?;
361            }
362        }
363
364        let removed = victims.len();
365        let new_count = commit_counts(&mut inner, -(removed as i64), -chars_removed)?;
366        atomic.commit();
367
368        undo_redo_commands::clear_stack(&inner.ctx, stack);
369
370        // Everything shifts down by what was cut off the front.
371        inner.invalidate_text_cache();
372        inner.adjust_cursors(0, chars_removed as usize + removed, 0);
373        inner.modified = true;
374        inner.queue_event(DocumentEvent::ContentsChanged {
375            position: 0,
376            chars_removed: chars_removed as usize + removed,
377            chars_added: 0,
378            blocks_affected: removed,
379        });
380        inner.queue_event(DocumentEvent::BlockCountChanged(new_count));
381        inner.queue_event(DocumentEvent::FlowElementsRemoved {
382            flow_index: 0,
383            count: removed,
384        });
385
386        // Dispatch to `on_change` subscribers (outside the lock — a callback may
387        // re-enter), the same as an ordinary edit.
388        let queued = inner.take_queued_events();
389        drop(inner);
390        crate::inner::dispatch_queued_events(queued);
391        Ok(removed)
392    }
393}
394
395/// The document's block count, read straight off the entity.
396///
397/// [`TextDocument::block_count`] answers the same question via
398/// `get_document_stats`, which also computes a word count by materializing every
399/// block's text — 3.18 ms at 10 000 lines. This is the cached value the entity
400/// already carries, which is what `inner.rs`'s own `check_block_count_changed`
401/// reads for exactly the same reason.
402fn current_block_count(inner: &TextDocumentInner) -> Result<i64> {
403    let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
404        .ok_or_else(|| DocumentError::InvalidArgument("document missing".into()))?;
405    Ok(doc.block_count)
406}
407
408/// A private undo stack for streaming writes, created on first use.
409///
410/// The entity commands always push an undo command — passing `None` does not
411/// opt out, it resolves to stack 0 (`add_command_to_stack`). Routing these
412/// writes to a stack of their own, cleared as they go, keeps them off the
413/// document's real history without bounding-problems or clearing what the user
414/// did.
415fn streaming_stack(inner: &mut TextDocumentInner) -> u64 {
416    if let Some(id) = inner.streaming_stack_id {
417        return id;
418    }
419    let id = undo_redo_commands::create_new_stack(&inner.ctx);
420    inner.streaming_stack_id = Some(id);
421    id
422}
423
424/// All-or-nothing around a streaming mutation.
425///
426/// These paths hand-maintain four things that must agree — the rope, the block
427/// offset index, the frame's `child_order`, and the document's cached counts —
428/// across several fallible calls. The ordinary editing path gets that
429/// consistency from the unit-of-work layer; taking the shortcut past it takes
430/// the shortcut past its atomicity too, so every early `?` would otherwise be a
431/// chance to leave the four disagreeing: text in the rope that no block owns, a
432/// block absent from `child_order`, counts describing a document that no longer
433/// exists.
434///
435/// A savepoint is a whole-store snapshot, but a cheap one — the rope is a
436/// persistent structure and the entity tables are `im::HashMap`s, so it is
437/// pointer-copies rather than a deep clone. Restoring it undoes everything
438/// since, the inner commands' own writes included, because each savepoint is an
439/// independent snapshot rather than a stack position.
440///
441/// Deliberately not `Transaction`, which is the same savepoint plus an
442/// O(N) `recompute_all_frame_byte_ranges` on commit — a cost this path exists
443/// to avoid, and one the inner commands' own transactions already pay.
444///
445/// Rolls back on drop unless [`commit`](Self::commit) ran, so an early `?` — or
446/// a panic — cannot leave the document half-written.
447struct Atomic {
448    /// Owned rather than borrowed: holding a reference would borrow the
449    /// document's interior for the guard's whole lifetime, which is exactly the
450    /// span in which the work needs `&mut` access to it.
451    store: Arc<common::database::Store>,
452    savepoint: Option<u64>,
453}
454
455impl Atomic {
456    fn begin(inner: &TextDocumentInner) -> Self {
457        let store = Arc::clone(inner.ctx.db_context.get_store());
458        let savepoint = Some(store.create_savepoint());
459        Self { store, savepoint }
460    }
461
462    /// Keep the mutations and drop the snapshot.
463    fn commit(mut self) {
464        if let Some(sp) = self.savepoint.take() {
465            self.store.discard_savepoint(sp);
466        }
467    }
468}
469
470impl Drop for Atomic {
471    fn drop(&mut self) {
472        // Still holding the savepoint means `commit` never ran: the operation
473        // left early, so put the document back exactly as it was.
474        if let Some(sp) = self.savepoint.take() {
475            self.store.restore_savepoint(sp);
476            self.store.discard_savepoint(sp);
477        }
478    }
479}
480
481/// What one appended line did to the document, as the caller's bookkeeping
482/// needs to describe it.
483struct Appended {
484    /// Document position the insertion began at.
485    edit_pos: usize,
486    /// Characters inserted there — the text, plus the separator when one was
487    /// needed. Not derivable from the text alone, which is the whole reason
488    /// this is reported rather than recomputed.
489    chars_added: usize,
490}
491
492/// Create one block, mirror it into the rope at the tail, and reference it from
493/// the frame.
494fn append_one(inner: &mut TextDocumentInner, frame_id: EntityId, text: &str) -> Result<Appended> {
495    let stack = streaming_stack(inner);
496
497    // Read the frame *before* creating the block: the generic create path adds
498    // the block to the junction table but not to `child_order`, so this sees
499    // exactly the blocks that already existed.
500    let frame = frame_commands::get_frame(&inner.ctx, &frame_id)?
501        .ok_or_else(|| DocumentError::InvalidArgument("main frame missing".into()))?;
502
503    // Blocks after the first in a frame are separated by a `\n` sentinel, and
504    // `rope_append_block` does not add one itself.
505    //
506    // The condition is "does the frame already hold a block", NOT "is the rope
507    // non-empty". A freshly created document already holds one *empty* block,
508    // registered at byte 0, with an empty rope — so keying off the rope would
509    // skip the separator and register the new block at byte 0 as well, leaving
510    // two blocks at the same offset with no `\n` between them. That is not a
511    // hypothetical: `TextDocument::new()` followed by `append_line` is the most
512    // direct way to use this API.
513    let needs_boundary = frame.child_order.iter().any(|entry| *entry > 0);
514
515    let block = block_commands::create_block(
516        &inner.ctx,
517        Some(stack),
518        &CreateBlockDto::default(),
519        frame_id,
520        -1,
521    )?;
522
523    let appended = {
524        let store = inner.ctx.db_context.get_store();
525        // Where the insertion begins — captured before it, so it covers the
526        // separator too when one is written.
527        let edit_pos = store.rope.read().len_chars();
528        if needs_boundary {
529            common::database::rope_helpers::rope_insert_block_boundary(store);
530        }
531        common::database::rope_helpers::rope_append_block(store, block.id, text);
532        Appended {
533            edit_pos,
534            chars_added: text.chars().count() + usize::from(needs_boundary),
535        }
536    };
537
538    // The generic create path adds the block to the junction table but not to
539    // `child_order`, which is what `flow()` reads — `initialize` notes the same.
540    let mut update: UpdateFrameDto = frame.into();
541    update.child_order.push(block.id as i64);
542    frame_commands::update_frame(&inner.ctx, Some(stack), &update)?;
543
544    Ok(appended)
545}
546
547/// Apply deltas to the document's cached counts, returning the new block count.
548fn commit_counts(
549    inner: &mut TextDocumentInner,
550    block_delta: i64,
551    char_delta: i64,
552) -> Result<usize> {
553    let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
554        .ok_or_else(|| DocumentError::InvalidArgument("document missing".into()))?;
555    let stack = streaming_stack(inner);
556    let mut update: UpdateDocumentDto = doc.into();
557    update.block_count = (update.block_count + block_delta).max(0);
558    update.character_count = (update.character_count + char_delta).max(0);
559    let new_count = update.block_count as usize;
560    document_commands::update_document(&inner.ctx, Some(stack), &update)?;
561    Ok(new_count)
562}
563
564/// Post-append bookkeeping shared by the append entry points.
565///
566/// `blocks_affected` is passed rather than assumed: a batch adds as many blocks
567/// as it has lines, and consumers size work off that number.
568fn finish(inner: &mut TextDocumentInner, edit_pos: usize, added: usize, blocks_affected: usize) {
569    let stack = streaming_stack(inner);
570    // Discard the throwaway history: these writes are not undoable, and letting
571    // it grow would leak a command per appended line.
572    undo_redo_commands::clear_stack(&inner.ctx, stack);
573
574    // The document's text just changed, so the lazily-built plain-text cache no
575    // longer describes it. Every ordinary edit clears this; bypassing the
576    // editing path means bypassing that too, and a stale cache is silent —
577    // `blocks()` reads the live document while `to_plain_text()` keeps serving
578    // whatever the text was when it was last asked.
579    inner.invalidate_text_cache();
580    inner.adjust_cursors(edit_pos, 0, added);
581    inner.modified = true;
582    inner.queue_event(DocumentEvent::ContentsChanged {
583        position: edit_pos,
584        chars_removed: 0,
585        chars_added: added,
586        blocks_affected,
587    });
588}