Skip to main content

text_document/
document.rs

1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use anyhow::Result;
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{ResourceType, TextDirection, WrapMode};
12use frontend::commands::{
13    block_commands, document_commands, document_inspection_commands, document_io_commands,
14    document_search_commands, frame_commands, resource_commands, undo_redo_commands,
15};
16
17use crate::convert::{self, to_i64, to_usize};
18use crate::cursor::TextCursor;
19use crate::events::{self, DocumentEvent, Subscription};
20use crate::flow::FormatChangeKind;
21use crate::inner::TextDocumentInner;
22use crate::operation::{DocxExportResult, HtmlImportResult, MarkdownImportResult, Operation};
23use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions};
24
25/// A rich text document.
26///
27/// Owns the backend (database, event hub, undo/redo manager) and provides
28/// document-level operations. All cursor-based editing goes through
29/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
30/// [`cursor_at()`](TextDocument::cursor_at).
31///
32/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
33/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
34/// handle to the **same** underlying document (like Qt's implicit sharing).
35#[derive(Clone)]
36pub struct TextDocument {
37    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
38}
39
40impl TextDocument {
41    // ── Construction ──────────────────────────────────────────
42
43    /// Create a new, empty document.
44    ///
45    /// # Panics
46    ///
47    /// Panics if the database context cannot be created (e.g. filesystem error).
48    /// Use [`TextDocument::try_new`] for a fallible alternative.
49    pub fn new() -> Self {
50        Self::try_new().expect("failed to initialize document")
51    }
52
53    /// Create a new, empty document, returning an error on failure.
54    pub fn try_new() -> Result<Self> {
55        let ctx = frontend::AppContext::new();
56        let doc_inner = TextDocumentInner::initialize(ctx)?;
57        let inner = Arc::new(Mutex::new(doc_inner));
58
59        // Bridge backend long-operation events to public DocumentEvent.
60        Self::subscribe_long_operation_events(&inner);
61
62        Ok(Self { inner })
63    }
64
65    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
66    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
67        use frontend::common::event::{LongOperationEvent as LOE, Origin};
68
69        let weak = Arc::downgrade(inner);
70        {
71            let locked = inner.lock();
72            // Progress
73            let w = weak.clone();
74            locked
75                .event_client
76                .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
77                    if let Some(inner) = w.upgrade() {
78                        let (op_id, percent, message) = parse_progress_data(&event.data);
79                        let mut inner = inner.lock();
80                        inner.queue_event(DocumentEvent::LongOperationProgress {
81                            operation_id: op_id,
82                            percent,
83                            message,
84                        });
85                    }
86                });
87
88            // Completed
89            let w = weak.clone();
90            locked
91                .event_client
92                .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
93                    if let Some(inner) = w.upgrade() {
94                        let op_id = parse_id_data(&event.data);
95                        let mut inner = inner.lock();
96                        inner.queue_event(DocumentEvent::DocumentReset);
97                        inner.check_block_count_changed();
98                        inner.reset_cached_child_order();
99                        inner.queue_event(DocumentEvent::LongOperationFinished {
100                            operation_id: op_id,
101                            success: true,
102                            error: None,
103                        });
104                    }
105                });
106
107            // Cancelled
108            let w = weak.clone();
109            locked
110                .event_client
111                .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
112                    if let Some(inner) = w.upgrade() {
113                        let op_id = parse_id_data(&event.data);
114                        let mut inner = inner.lock();
115                        inner.queue_event(DocumentEvent::LongOperationFinished {
116                            operation_id: op_id,
117                            success: false,
118                            error: Some("cancelled".into()),
119                        });
120                    }
121                });
122
123            // Failed
124            locked
125                .event_client
126                .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
127                    if let Some(inner) = weak.upgrade() {
128                        let (op_id, error) = parse_failed_data(&event.data);
129                        let mut inner = inner.lock();
130                        inner.queue_event(DocumentEvent::LongOperationFinished {
131                            operation_id: op_id,
132                            success: false,
133                            error: Some(error),
134                        });
135                    }
136                });
137        }
138    }
139
140    // ── Whole-document content ────────────────────────────────
141
142    /// Replace the entire document with plain text. Clears undo history.
143    pub fn set_plain_text(&self, text: &str) -> Result<()> {
144        let queued = {
145            let mut inner = self.inner.lock();
146            let dto = frontend::document_io::ImportPlainTextDto {
147                plain_text: text.into(),
148            };
149            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
150            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
151            inner.invalidate_text_cache();
152            inner.rehighlight_all();
153            inner.queue_event(DocumentEvent::DocumentReset);
154            inner.check_block_count_changed();
155            inner.reset_cached_child_order();
156            inner.queue_event(DocumentEvent::UndoRedoChanged {
157                can_undo: false,
158                can_redo: false,
159            });
160            inner.take_queued_events()
161        };
162        crate::inner::dispatch_queued_events(queued);
163        Ok(())
164    }
165
166    /// Export the entire document as plain text.
167    pub fn to_plain_text(&self) -> Result<String> {
168        let mut inner = self.inner.lock();
169        Ok(inner.plain_text()?.to_string())
170    }
171
172    /// Replace the entire document with Markdown. Clears undo history.
173    ///
174    /// This is a **long operation**. Returns a typed [`Operation`] handle.
175    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
176        let mut inner = self.inner.lock();
177        inner.invalidate_text_cache();
178        let dto = frontend::document_io::ImportMarkdownDto {
179            markdown_text: markdown.into(),
180        };
181        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
182        Ok(Operation::new(
183            op_id,
184            &inner.ctx,
185            Box::new(|ctx, id| {
186                document_io_commands::get_import_markdown_result(ctx, id)
187                    .ok()
188                    .flatten()
189                    .map(|r| {
190                        Ok(MarkdownImportResult {
191                            block_count: to_usize(r.block_count),
192                        })
193                    })
194            }),
195        ))
196    }
197
198    /// Export the entire document as Markdown.
199    pub fn to_markdown(&self) -> Result<String> {
200        let inner = self.inner.lock();
201        let dto = document_io_commands::export_markdown(&inner.ctx)?;
202        Ok(dto.markdown_text)
203    }
204
205    /// Replace the entire document with HTML. Clears undo history.
206    ///
207    /// This is a **long operation**. Returns a typed [`Operation`] handle.
208    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
209        let mut inner = self.inner.lock();
210        inner.invalidate_text_cache();
211        let dto = frontend::document_io::ImportHtmlDto {
212            html_text: html.into(),
213        };
214        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
215        Ok(Operation::new(
216            op_id,
217            &inner.ctx,
218            Box::new(|ctx, id| {
219                document_io_commands::get_import_html_result(ctx, id)
220                    .ok()
221                    .flatten()
222                    .map(|r| {
223                        Ok(HtmlImportResult {
224                            block_count: to_usize(r.block_count),
225                        })
226                    })
227            }),
228        ))
229    }
230
231    /// Export the entire document as HTML.
232    pub fn to_html(&self) -> Result<String> {
233        let inner = self.inner.lock();
234        let dto = document_io_commands::export_html(&inner.ctx)?;
235        Ok(dto.html_text)
236    }
237
238    /// Export the entire document as LaTeX.
239    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
240        let inner = self.inner.lock();
241        let dto = frontend::document_io::ExportLatexDto {
242            document_class: document_class.into(),
243            include_preamble,
244        };
245        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
246        Ok(result.latex_text)
247    }
248
249    /// Export the entire document as DOCX to a file path.
250    ///
251    /// This is a **long operation**. Returns a typed [`Operation`] handle.
252    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
253        let inner = self.inner.lock();
254        let dto = frontend::document_io::ExportDocxDto {
255            output_path: output_path.into(),
256        };
257        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
258        Ok(Operation::new(
259            op_id,
260            &inner.ctx,
261            Box::new(|ctx, id| {
262                document_io_commands::get_export_docx_result(ctx, id)
263                    .ok()
264                    .flatten()
265                    .map(|r| {
266                        Ok(DocxExportResult {
267                            file_path: r.file_path,
268                            paragraph_count: to_usize(r.paragraph_count),
269                        })
270                    })
271            }),
272        ))
273    }
274
275    /// Clear all document content and reset to an empty state.
276    pub fn clear(&self) -> Result<()> {
277        let queued = {
278            let mut inner = self.inner.lock();
279            let dto = frontend::document_io::ImportPlainTextDto {
280                plain_text: String::new(),
281            };
282            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
283            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
284            inner.invalidate_text_cache();
285            inner.rehighlight_all();
286            inner.queue_event(DocumentEvent::DocumentReset);
287            inner.check_block_count_changed();
288            inner.reset_cached_child_order();
289            inner.queue_event(DocumentEvent::UndoRedoChanged {
290                can_undo: false,
291                can_redo: false,
292            });
293            inner.take_queued_events()
294        };
295        crate::inner::dispatch_queued_events(queued);
296        Ok(())
297    }
298
299    // ── Cursor factory ───────────────────────────────────────
300
301    /// Create a cursor at position 0.
302    pub fn cursor(&self) -> TextCursor {
303        self.cursor_at(0)
304    }
305
306    /// Create a cursor at the given position.
307    pub fn cursor_at(&self, position: usize) -> TextCursor {
308        let data = {
309            let mut inner = self.inner.lock();
310            inner.register_cursor(position)
311        };
312        TextCursor {
313            doc: self.inner.clone(),
314            data,
315        }
316    }
317
318    // ── Document queries ─────────────────────────────────────
319
320    /// Get document statistics. O(1) — reads cached values.
321    pub fn stats(&self) -> DocumentStats {
322        let inner = self.inner.lock();
323        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
324            .expect("get_document_stats should not fail");
325        DocumentStats::from(&dto)
326    }
327
328    /// Get the total character count. O(1) — reads cached value.
329    pub fn character_count(&self) -> usize {
330        let inner = self.inner.lock();
331        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
332            .expect("get_document_stats should not fail");
333        to_usize(dto.character_count)
334    }
335
336    /// Get the number of blocks (paragraphs). O(1) — reads cached value.
337    pub fn block_count(&self) -> usize {
338        let inner = self.inner.lock();
339        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
340            .expect("get_document_stats should not fail");
341        to_usize(dto.block_count)
342    }
343
344    /// Returns true if the document has no text content.
345    pub fn is_empty(&self) -> bool {
346        self.character_count() == 0
347    }
348
349    /// Get text at a position for a given length.
350    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
351        let inner = self.inner.lock();
352        let dto = frontend::document_inspection::GetTextAtPositionDto {
353            position: to_i64(position),
354            length: to_i64(length),
355        };
356        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
357        Ok(result.text)
358    }
359
360    /// Get info about the block at a position. O(log n).
361    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
362        let inner = self.inner.lock();
363        let dto = frontend::document_inspection::GetBlockAtPositionDto {
364            position: to_i64(position),
365        };
366        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
367        Ok(BlockInfo::from(&result))
368    }
369
370    /// Get the block format at a position.
371    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
372        let inner = self.inner.lock();
373        let dto = frontend::document_inspection::GetBlockAtPositionDto {
374            position: to_i64(position),
375        };
376        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
377        let block_id = block_info.block_id;
378        let block_id = block_id as u64;
379        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
380            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
381        Ok(BlockFormat::from(&block_dto))
382    }
383
384    // ── Flow traversal (layout engine API) ─────────────────
385
386    /// Walk the main frame's visual flow in document order.
387    ///
388    /// Returns the top-level flow elements — blocks, tables, and
389    /// sub-frames — in the order defined by the main frame's
390    /// `child_order`. Table cell contents are NOT included here;
391    /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
392    ///
393    /// This is the primary entry point for layout initialization.
394    pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
395        let inner = self.inner.lock();
396        let main_frame_id = get_main_frame_id(&inner);
397        crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
398    }
399
400    /// Get a read-only handle to a block by its entity ID.
401    ///
402    /// Entity IDs are stable across insertions and deletions.
403    /// Returns `None` if no block with this ID exists.
404    pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
405        let inner = self.inner.lock();
406        let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
407            .ok()
408            .flatten()
409            .is_some();
410
411        if exists {
412            Some(crate::text_block::TextBlock {
413                doc: self.inner.clone(),
414                block_id,
415            })
416        } else {
417            None
418        }
419    }
420
421    /// Build a single `BlockSnapshot` for the block at the given position.
422    ///
423    /// This is O(k) where k = inline elements in that block, compared to
424    /// `snapshot_flow()` which is O(n) over the entire document.
425    /// Use for incremental layout updates after single-block edits.
426    pub fn snapshot_block_at_position(
427        &self,
428        position: usize,
429    ) -> Option<crate::flow::BlockSnapshot> {
430        let inner = self.inner.lock();
431        let main_frame_id = get_main_frame_id(&inner);
432
433        // Collect all block IDs in document order, traversing into nested frames
434        let ordered_block_ids = collect_frame_block_ids(&inner, main_frame_id)?;
435
436        // Walk blocks computing positions on the fly
437        let pos = position as i64;
438        let mut running_pos: i64 = 0;
439        for &block_id in &ordered_block_ids {
440            let block_dto = block_commands::get_block(&inner.ctx, &block_id)
441                .ok()
442                .flatten()?;
443            let block_end = running_pos + block_dto.text_length;
444            if pos >= running_pos && pos <= block_end {
445                return crate::text_block::build_block_snapshot_with_position(
446                    &inner,
447                    block_id,
448                    Some(running_pos as usize),
449                );
450            }
451            running_pos = block_end + 1;
452        }
453
454        // Fallback to last block
455        if let Some(&last_id) = ordered_block_ids.last() {
456            return crate::text_block::build_block_snapshot(&inner, last_id);
457        }
458        None
459    }
460
461    /// Get a read-only handle to the block containing the given
462    /// character position. Returns `None` if position is out of range.
463    pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
464        let inner = self.inner.lock();
465        let dto = frontend::document_inspection::GetBlockAtPositionDto {
466            position: to_i64(position),
467        };
468        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
469        Some(crate::text_block::TextBlock {
470            doc: self.inner.clone(),
471            block_id: result.block_id as usize,
472        })
473    }
474
475    /// Get a read-only handle to a block by its 0-indexed global
476    /// block number.
477    ///
478    /// **O(n)**: requires scanning all blocks sorted by
479    /// `document_position` to find the nth one. Prefer
480    /// [`block_at_position()`](TextDocument::block_at_position) or
481    /// [`block_by_id()`](TextDocument::block_by_id) in
482    /// performance-sensitive paths.
483    pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
484        let inner = self.inner.lock();
485        let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
486        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
487        sorted.sort_by_key(|b| b.document_position);
488
489        sorted
490            .get(block_number)
491            .map(|b| crate::text_block::TextBlock {
492                doc: self.inner.clone(),
493                block_id: b.id as usize,
494            })
495    }
496
497    /// All blocks in the document, sorted by `document_position`. **O(n)**.
498    ///
499    /// Returns blocks from all frames, including those inside table cells.
500    /// This is the efficient way to iterate all blocks — avoids the O(n^2)
501    /// cost of calling `block_by_number(i)` in a loop.
502    pub fn blocks(&self) -> Vec<crate::text_block::TextBlock> {
503        let inner = self.inner.lock();
504        let all_blocks =
505            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
506        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
507        sorted.sort_by_key(|b| b.document_position);
508        sorted
509            .iter()
510            .map(|b| crate::text_block::TextBlock {
511                doc: self.inner.clone(),
512                block_id: b.id as usize,
513            })
514            .collect()
515    }
516
517    /// All blocks whose character range intersects `[position, position + length)`.
518    ///
519    /// **O(n)**: scans all blocks once. Returns them sorted by `document_position`.
520    /// A block intersects if its range `[block.position, block.position + block.length)`
521    /// overlaps the query range. An empty query range (`length == 0`) returns the
522    /// block containing that position, if any.
523    pub fn blocks_in_range(
524        &self,
525        position: usize,
526        length: usize,
527    ) -> Vec<crate::text_block::TextBlock> {
528        let inner = self.inner.lock();
529        let all_blocks =
530            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
531        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
532        sorted.sort_by_key(|b| b.document_position);
533
534        let range_start = position;
535        let range_end = position + length;
536
537        sorted
538            .iter()
539            .filter(|b| {
540                let block_start = b.document_position.max(0) as usize;
541                let block_end = block_start + b.text_length.max(0) as usize;
542                // Overlap check: block intersects [range_start, range_end)
543                if length == 0 {
544                    // Point query: block contains the position
545                    range_start >= block_start && range_start < block_end
546                } else {
547                    block_start < range_end && block_end > range_start
548                }
549            })
550            .map(|b| crate::text_block::TextBlock {
551                doc: self.inner.clone(),
552                block_id: b.id as usize,
553            })
554            .collect()
555    }
556
557    /// Snapshot the entire main flow in a single lock acquisition.
558    ///
559    /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
560    /// for every element in the flow.
561    pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
562        let inner = self.inner.lock();
563        let main_frame_id = get_main_frame_id(&inner);
564        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id);
565        crate::flow::FlowSnapshot { elements }
566    }
567
568    // ── Search ───────────────────────────────────────────────
569
570    /// Find the next (or previous) occurrence. Returns `None` if not found.
571    pub fn find(
572        &self,
573        query: &str,
574        from: usize,
575        options: &FindOptions,
576    ) -> Result<Option<FindMatch>> {
577        let inner = self.inner.lock();
578        let dto = options.to_find_text_dto(query, from);
579        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
580        Ok(convert::find_result_to_match(&result))
581    }
582
583    /// Find all occurrences.
584    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
585        let inner = self.inner.lock();
586        let dto = options.to_find_all_dto(query);
587        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
588        Ok(convert::find_all_to_matches(&result))
589    }
590
591    /// Replace occurrences. Returns the number of replacements. Undoable.
592    pub fn replace_text(
593        &self,
594        query: &str,
595        replacement: &str,
596        replace_all: bool,
597        options: &FindOptions,
598    ) -> Result<usize> {
599        let (count, queued) = {
600            let mut inner = self.inner.lock();
601            let dto = options.to_replace_dto(query, replacement, replace_all);
602            let result =
603                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
604            let count = to_usize(result.replacements_count);
605            inner.invalidate_text_cache();
606            if count > 0 {
607                inner.modified = true;
608                inner.rehighlight_all();
609                // Replacements are scattered across the document — we can't
610                // provide a single position/chars delta. Signal "content changed
611                // from position 0, affecting `count` sites" so the consumer
612                // knows to re-read.
613                inner.queue_event(DocumentEvent::ContentsChanged {
614                    position: 0,
615                    chars_removed: 0,
616                    chars_added: 0,
617                    blocks_affected: count,
618                });
619                inner.check_block_count_changed();
620                inner.check_flow_changed();
621                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
622                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
623                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
624            }
625            (count, inner.take_queued_events())
626        };
627        crate::inner::dispatch_queued_events(queued);
628        Ok(count)
629    }
630
631    // ── Resources ────────────────────────────────────────────
632
633    /// Add a resource (image, stylesheet) to the document.
634    pub fn add_resource(
635        &self,
636        resource_type: ResourceType,
637        name: &str,
638        mime_type: &str,
639        data: &[u8],
640    ) -> Result<()> {
641        let mut inner = self.inner.lock();
642        let dto = frontend::resource::dtos::CreateResourceDto {
643            created_at: Default::default(),
644            updated_at: Default::default(),
645            resource_type,
646            name: name.into(),
647            url: String::new(),
648            mime_type: mime_type.into(),
649            data_base64: BASE64.encode(data),
650        };
651        let created = resource_commands::create_resource(
652            &inner.ctx,
653            Some(inner.stack_id),
654            &dto,
655            inner.document_id,
656            -1,
657        )?;
658        inner.resource_cache.insert(name.to_string(), created.id);
659        Ok(())
660    }
661
662    /// Get a resource by name. Returns `None` if not found.
663    ///
664    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
665    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
666        let mut inner = self.inner.lock();
667
668        // Fast path: check the name → ID cache.
669        if let Some(&id) = inner.resource_cache.get(name) {
670            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
671                let bytes = BASE64.decode(&r.data_base64)?;
672                return Ok(Some(bytes));
673            }
674            // ID was stale — fall through to full scan.
675            inner.resource_cache.remove(name);
676        }
677
678        // Slow path: linear scan, then populate cache for the match.
679        let all = resource_commands::get_all_resource(&inner.ctx)?;
680        for r in &all {
681            if r.name == name {
682                inner.resource_cache.insert(name.to_string(), r.id);
683                let bytes = BASE64.decode(&r.data_base64)?;
684                return Ok(Some(bytes));
685            }
686        }
687        Ok(None)
688    }
689
690    // ── Undo / Redo ──────────────────────────────────────────
691
692    /// Undo the last operation.
693    pub fn undo(&self) -> Result<()> {
694        let queued = {
695            let mut inner = self.inner.lock();
696            let before = capture_block_state(&inner);
697            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
698            inner.invalidate_text_cache();
699            result?;
700            inner.rehighlight_all();
701            emit_undo_redo_change_events(&mut inner, &before);
702            inner.check_block_count_changed();
703            inner.check_flow_changed();
704            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
705            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
706            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
707            inner.take_queued_events()
708        };
709        crate::inner::dispatch_queued_events(queued);
710        Ok(())
711    }
712
713    /// Redo the last undone operation.
714    pub fn redo(&self) -> Result<()> {
715        let queued = {
716            let mut inner = self.inner.lock();
717            let before = capture_block_state(&inner);
718            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
719            inner.invalidate_text_cache();
720            result?;
721            inner.rehighlight_all();
722            emit_undo_redo_change_events(&mut inner, &before);
723            inner.check_block_count_changed();
724            inner.check_flow_changed();
725            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
726            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
727            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
728            inner.take_queued_events()
729        };
730        crate::inner::dispatch_queued_events(queued);
731        Ok(())
732    }
733
734    /// Returns true if there are operations that can be undone.
735    pub fn can_undo(&self) -> bool {
736        let inner = self.inner.lock();
737        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
738    }
739
740    /// Returns true if there are operations that can be redone.
741    pub fn can_redo(&self) -> bool {
742        let inner = self.inner.lock();
743        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
744    }
745
746    /// Clear all undo/redo history.
747    pub fn clear_undo_redo(&self) {
748        let inner = self.inner.lock();
749        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
750    }
751
752    // ── Modified state ───────────────────────────────────────
753
754    /// Returns true if the document has been modified since creation or last reset.
755    pub fn is_modified(&self) -> bool {
756        self.inner.lock().modified
757    }
758
759    /// Set or clear the modified flag.
760    pub fn set_modified(&self, modified: bool) {
761        let queued = {
762            let mut inner = self.inner.lock();
763            if inner.modified != modified {
764                inner.modified = modified;
765                inner.queue_event(DocumentEvent::ModificationChanged(modified));
766            }
767            inner.take_queued_events()
768        };
769        crate::inner::dispatch_queued_events(queued);
770    }
771
772    // ── Document properties ──────────────────────────────────
773
774    /// Get the document title.
775    pub fn title(&self) -> String {
776        let inner = self.inner.lock();
777        document_commands::get_document(&inner.ctx, &inner.document_id)
778            .ok()
779            .flatten()
780            .map(|d| d.title)
781            .unwrap_or_default()
782    }
783
784    /// Set the document title.
785    pub fn set_title(&self, title: &str) -> Result<()> {
786        let inner = self.inner.lock();
787        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
788            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
789        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
790        update.title = title.into();
791        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
792        Ok(())
793    }
794
795    /// Get the text direction.
796    pub fn text_direction(&self) -> TextDirection {
797        let inner = self.inner.lock();
798        document_commands::get_document(&inner.ctx, &inner.document_id)
799            .ok()
800            .flatten()
801            .map(|d| d.text_direction)
802            .unwrap_or(TextDirection::LeftToRight)
803    }
804
805    /// Set the text direction.
806    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
807        let inner = self.inner.lock();
808        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
809            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
810        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
811        update.text_direction = direction;
812        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
813        Ok(())
814    }
815
816    /// Get the default wrap mode.
817    pub fn default_wrap_mode(&self) -> WrapMode {
818        let inner = self.inner.lock();
819        document_commands::get_document(&inner.ctx, &inner.document_id)
820            .ok()
821            .flatten()
822            .map(|d| d.default_wrap_mode)
823            .unwrap_or(WrapMode::WordWrap)
824    }
825
826    /// Set the default wrap mode.
827    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
828        let inner = self.inner.lock();
829        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
830            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
831        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
832        update.default_wrap_mode = mode;
833        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
834        Ok(())
835    }
836
837    // ── Event subscription ───────────────────────────────────
838
839    /// Subscribe to document events via callback.
840    ///
841    /// Callbacks are invoked **outside** the document lock (after the editing
842    /// operation completes and the lock is released). It is safe to call
843    /// `TextDocument` or `TextCursor` methods from within the callback without
844    /// risk of deadlock. However, keep callbacks lightweight — they run
845    /// synchronously on the calling thread and block the caller until they
846    /// return.
847    ///
848    /// Drop the returned [`Subscription`] to unsubscribe.
849    ///
850    /// # Breaking change (v0.0.6)
851    ///
852    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
853    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
854    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
855    pub fn on_change<F>(&self, callback: F) -> Subscription
856    where
857        F: Fn(DocumentEvent) + Send + Sync + 'static,
858    {
859        let mut inner = self.inner.lock();
860        events::subscribe_inner(&mut inner, callback)
861    }
862
863    /// Return events accumulated since the last `poll_events()` call.
864    ///
865    /// This delivery path is independent of callback dispatch via
866    /// [`on_change`](Self::on_change) — using both simultaneously is safe
867    /// and each path sees every event exactly once.
868    pub fn poll_events(&self) -> Vec<DocumentEvent> {
869        let mut inner = self.inner.lock();
870        inner.drain_poll_events()
871    }
872
873    // ── Syntax highlighting ──────────────────────────────────
874
875    /// Attach a syntax highlighter to this document.
876    ///
877    /// Immediately re-highlights the entire document. Replaces any
878    /// previously attached highlighter. Pass `None` to remove the
879    /// highlighter and clear all highlight formatting.
880    pub fn set_syntax_highlighter(&self, highlighter: Option<Arc<dyn crate::SyntaxHighlighter>>) {
881        let mut inner = self.inner.lock();
882        match highlighter {
883            Some(hl) => {
884                inner.highlight = Some(crate::highlight::HighlightData {
885                    highlighter: hl,
886                    blocks: std::collections::HashMap::new(),
887                });
888                inner.rehighlight_all();
889            }
890            None => {
891                inner.highlight = None;
892            }
893        }
894    }
895
896    /// Re-highlight the entire document.
897    ///
898    /// Call this when the highlighter's rules change (e.g., new keywords
899    /// were added, spellcheck dictionary updated).
900    pub fn rehighlight(&self) {
901        let mut inner = self.inner.lock();
902        inner.rehighlight_all();
903    }
904
905    /// Re-highlight a single block and cascade to subsequent blocks if
906    /// the block state changes.
907    pub fn rehighlight_block(&self, block_id: usize) {
908        let mut inner = self.inner.lock();
909        inner.rehighlight_from_block(block_id);
910    }
911}
912
913impl Default for TextDocument {
914    fn default() -> Self {
915        Self::new()
916    }
917}
918
919// ── Undo/redo change detection helpers ─────────────────────────
920
921/// Lightweight block state for before/after comparison during undo/redo.
922struct UndoBlockState {
923    id: u64,
924    position: i64,
925    text_length: i64,
926    plain_text: String,
927    format: BlockFormat,
928}
929
930/// Capture the state of all blocks, sorted by document_position.
931fn capture_block_state(inner: &TextDocumentInner) -> Vec<UndoBlockState> {
932    let all_blocks =
933        frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
934    let mut states: Vec<UndoBlockState> = all_blocks
935        .into_iter()
936        .map(|b| UndoBlockState {
937            id: b.id,
938            position: b.document_position,
939            text_length: b.text_length,
940            plain_text: b.plain_text.clone(),
941            format: BlockFormat::from(&b),
942        })
943        .collect();
944    states.sort_by_key(|s| s.position);
945    states
946}
947
948/// Build the full document text from sorted block states (joined with newlines).
949fn build_doc_text(states: &[UndoBlockState]) -> String {
950    states
951        .iter()
952        .map(|s| s.plain_text.as_str())
953        .collect::<Vec<_>>()
954        .join("\n")
955}
956
957/// Compute the precise edit between two strings by comparing common prefix and suffix.
958/// Returns `(edit_offset, chars_removed, chars_added)`.
959fn compute_text_edit(before: &str, after: &str) -> (usize, usize, usize) {
960    let before_chars: Vec<char> = before.chars().collect();
961    let after_chars: Vec<char> = after.chars().collect();
962
963    // Common prefix
964    let prefix_len = before_chars
965        .iter()
966        .zip(after_chars.iter())
967        .take_while(|(a, b)| a == b)
968        .count();
969
970    // Common suffix (not overlapping with prefix)
971    let before_remaining = before_chars.len() - prefix_len;
972    let after_remaining = after_chars.len() - prefix_len;
973    let suffix_len = before_chars
974        .iter()
975        .rev()
976        .zip(after_chars.iter().rev())
977        .take(before_remaining.min(after_remaining))
978        .take_while(|(a, b)| a == b)
979        .count();
980
981    let removed = before_remaining - suffix_len;
982    let added = after_remaining - suffix_len;
983
984    (prefix_len, removed, added)
985}
986
987/// Compare block state before and after undo/redo and emit
988/// ContentsChanged / FormatChanged events for affected regions.
989fn emit_undo_redo_change_events(inner: &mut TextDocumentInner, before: &[UndoBlockState]) {
990    let after = capture_block_state(inner);
991
992    // Build a map of block id → state for the "before" set.
993    let before_map: std::collections::HashMap<u64, &UndoBlockState> =
994        before.iter().map(|s| (s.id, s)).collect();
995    let after_map: std::collections::HashMap<u64, &UndoBlockState> =
996        after.iter().map(|s| (s.id, s)).collect();
997
998    // Track the affected content region (earliest position, total old/new length).
999    let mut content_changed = false;
1000    let mut earliest_pos: Option<usize> = None;
1001    let mut old_end: usize = 0;
1002    let mut new_end: usize = 0;
1003    let mut blocks_affected: usize = 0;
1004
1005    let mut format_only_changes: Vec<(usize, usize)> = Vec::new(); // (position, length)
1006
1007    // Check blocks present in both before and after.
1008    for after_state in &after {
1009        if let Some(before_state) = before_map.get(&after_state.id) {
1010            let text_changed = before_state.plain_text != after_state.plain_text
1011                || before_state.text_length != after_state.text_length;
1012            let format_changed = before_state.format != after_state.format;
1013
1014            if text_changed {
1015                content_changed = true;
1016                blocks_affected += 1;
1017                let pos = after_state.position.max(0) as usize;
1018                earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1019                old_end = old_end.max(
1020                    before_state.position.max(0) as usize
1021                        + before_state.text_length.max(0) as usize,
1022                );
1023                new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
1024            } else if format_changed {
1025                let pos = after_state.position.max(0) as usize;
1026                let len = after_state.text_length.max(0) as usize;
1027                format_only_changes.push((pos, len));
1028            }
1029        } else {
1030            // Block exists in after but not in before — new block from undo/redo.
1031            content_changed = true;
1032            blocks_affected += 1;
1033            let pos = after_state.position.max(0) as usize;
1034            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1035            new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
1036        }
1037    }
1038
1039    // Check blocks that were removed (present in before but not after).
1040    for before_state in before {
1041        if !after_map.contains_key(&before_state.id) {
1042            content_changed = true;
1043            blocks_affected += 1;
1044            let pos = before_state.position.max(0) as usize;
1045            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1046            old_end = old_end.max(pos + before_state.text_length.max(0) as usize);
1047        }
1048    }
1049
1050    if content_changed {
1051        let position = earliest_pos.unwrap_or(0);
1052        let chars_removed = old_end.saturating_sub(position);
1053        let chars_added = new_end.saturating_sub(position);
1054
1055        // Use a precise text-level diff for cursor adjustment so cursors land
1056        // at the actual edit point rather than the end of the affected block.
1057        let before_text = build_doc_text(before);
1058        let after_text = build_doc_text(&after);
1059        let (edit_offset, precise_removed, precise_added) =
1060            compute_text_edit(&before_text, &after_text);
1061        if precise_removed > 0 || precise_added > 0 {
1062            inner.adjust_cursors(edit_offset, precise_removed, precise_added);
1063        }
1064
1065        inner.queue_event(DocumentEvent::ContentsChanged {
1066            position,
1067            chars_removed,
1068            chars_added,
1069            blocks_affected,
1070        });
1071    }
1072
1073    // Emit FormatChanged for blocks where only formatting changed (not content).
1074    for (position, length) in format_only_changes {
1075        inner.queue_event(DocumentEvent::FormatChanged {
1076            position,
1077            length,
1078            kind: FormatChangeKind::Block,
1079        });
1080    }
1081}
1082
1083// ── Flow helpers ──────────────────────────────────────────────
1084
1085/// Get the main frame ID for the document.
1086/// Collect all block IDs in document order from a frame, recursing into nested
1087/// sub-frames (negative entries in child_order).
1088fn collect_frame_block_ids(
1089    inner: &TextDocumentInner,
1090    frame_id: frontend::common::types::EntityId,
1091) -> Option<Vec<u64>> {
1092    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
1093        .ok()
1094        .flatten()?;
1095
1096    if !frame_dto.child_order.is_empty() {
1097        let mut block_ids = Vec::new();
1098        for &entry in &frame_dto.child_order {
1099            if entry > 0 {
1100                block_ids.push(entry as u64);
1101            } else if entry < 0 {
1102                let sub_frame_id = (-entry) as u64;
1103                if let Some(sub_ids) = collect_frame_block_ids(inner, sub_frame_id) {
1104                    block_ids.extend(sub_ids);
1105                }
1106            }
1107        }
1108        Some(block_ids)
1109    } else {
1110        Some(frame_dto.blocks.to_vec())
1111    }
1112}
1113
1114fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
1115    // The document's first frame is the main frame.
1116    let frames = frontend::commands::document_commands::get_document_relationship(
1117        &inner.ctx,
1118        &inner.document_id,
1119        &frontend::document::dtos::DocumentRelationshipField::Frames,
1120    )
1121    .unwrap_or_default();
1122
1123    frames.first().copied().unwrap_or(0)
1124}
1125
1126// ── Long-operation event data helpers ─────────────────────────
1127
1128/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
1129fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
1130    let Some(json) = data else {
1131        return (String::new(), 0.0, String::new());
1132    };
1133    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1134    let id = v["id"].as_str().unwrap_or_default().to_string();
1135    let pct = v["percentage"].as_f64().unwrap_or(0.0);
1136    let msg = v["message"].as_str().unwrap_or_default().to_string();
1137    (id, pct, msg)
1138}
1139
1140/// Parse completed/cancelled JSON: `{"id":"..."}`
1141fn parse_id_data(data: &Option<String>) -> String {
1142    let Some(json) = data else {
1143        return String::new();
1144    };
1145    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1146    v["id"].as_str().unwrap_or_default().to_string()
1147}
1148
1149/// Parse failed JSON: `{"id":"...", "error":"..."}`
1150fn parse_failed_data(data: &Option<String>) -> (String, String) {
1151    let Some(json) = data else {
1152        return (String::new(), "unknown error".into());
1153    };
1154    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1155    let id = v["id"].as_str().unwrap_or_default().to_string();
1156    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
1157    (id, error)
1158}