Skip to main content

strop_engine/editor/document/
mod.rs

1//! The document lifecycle (0014 wave 2): the Document struct, the
2//! arena accessors, open/close/scratch, MRU. One document owns its text,
3//! highlighter, and git surface — no parallel vectors.
4
5use strop_core::Buffer;
6
7pub(crate) mod remote;
8pub mod surfaces;
9pub use remote::{RemoteDirectory, RemoteDocument};
10
11pub use surfaces::{DiffRow, DocumentSource, ReturnPoint, Surface};
12
13use super::Editor;
14
15/// One document: the text buffer plus everything that used to live in
16/// parallel vectors keyed by buffer index (0014 wave 2). One struct,
17/// one arena — the alignment invariant is the type system now.
18pub struct Document {
19    pub buf: Buffer,
20    /// What backs this document (0021 §4): the surface payload lives in
21    /// the source variant; readonly derives from it at construction.
22    pub source: DocumentSource,
23}
24
25impl Document {
26    pub fn new(buf: Buffer) -> Self {
27        Self {
28            buf,
29            source: DocumentSource::File,
30        }
31    }
32
33    /// A scratch document (no file).
34    pub fn scratch(buf: Buffer) -> Self {
35        Self {
36            buf,
37            source: DocumentSource::Scratch,
38        }
39    }
40
41    /// A git-memory surface: job-owned content, readonly derived from
42    /// the source — not set by hand (0021 §4).
43    pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
44        buf.readonly = true;
45        Self {
46            buf,
47            source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
48                context,
49                content: surface,
50            })),
51        }
52    }
53
54    /// Named virtual content (`:!cmd` output, help, the undo browser):
55    /// readonly derived from the source.
56    pub fn output(mut buf: Buffer) -> Self {
57        buf.readonly = true;
58        Self {
59            buf,
60            source: DocumentSource::Output,
61        }
62    }
63
64    /// A file read from a container (0037 DC1a/b): readonly derived from
65    /// the source; the path names container bytes, never a local file.
66    pub fn container_file(
67        mut buf: Buffer,
68        container: strop_workspace::ContainerId,
69        path: std::path::PathBuf,
70    ) -> Self {
71        buf.readonly = true;
72        Self {
73            buf,
74            source: DocumentSource::Container { container, path },
75        }
76    }
77
78    /// Syntax identity is data; parsers live on the display-analysis worker.
79    pub fn syntax_path(&self) -> Option<&std::path::Path> {
80        match &self.source {
81            DocumentSource::Remote(source) => Some(source.file.path()),
82            DocumentSource::Surface(source) => match &source.content {
83                Surface::Diff {
84                    commit: Some(commit),
85                    ..
86                } => Some(&commit.current),
87                Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
88                _ => None,
89            },
90            DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
91            DocumentSource::Container { path, .. } => Some(path),
92            DocumentSource::RemoteDirectory(_) | DocumentSource::Output => None,
93        }
94    }
95
96    pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
97        match (&self.source, target) {
98            (DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
99                location.absolute_file() == Some(&source.file)
100            }
101            (
102                DocumentSource::RemoteDirectory(source),
103                crate::files::FileTarget::Remote(location),
104            ) => location.absolute_file() == Some(&source.directory),
105            (DocumentSource::File, crate::files::FileTarget::Local(path)) => {
106                self.buf.path.as_ref() == Some(path)
107                    || self.buf.file_identity() == Some(path.as_path())
108            }
109            _ => false,
110        }
111    }
112
113    pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
114        use crate::files::FileTarget;
115        match &self.source {
116            DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
117            DocumentSource::RemoteDirectory(source) => {
118                Some(FileTarget::Remote(source.directory.clone().into()))
119            }
120            _ => self
121                .buf
122                .file_identity()
123                .or(self.buf.path.as_deref())
124                .map(|path| FileTarget::Local(cwd.join(path))),
125        }
126    }
127
128    /// The surface payload, when this document is one.
129    pub fn surface_payload(&self) -> Option<&Surface> {
130        match &self.source {
131            DocumentSource::Surface(s) => Some(&s.content),
132            _ => None,
133        }
134    }
135
136    /// Mutable surface payload, when this document is one.
137    pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
138        match &mut self.source {
139            DocumentSource::Surface(s) => Some(&mut s.content),
140            _ => None,
141        }
142    }
143
144    pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
145        match &self.source {
146            DocumentSource::Surface(surface) => Some(&surface.context),
147            _ => None,
148        }
149    }
150}
151
152impl Editor {
153    /// Mark a document most-recently-used.
154    pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
155        self.mru.retain(|&x| x != i);
156        self.mru.insert(0, i);
157    }
158
159    /// The current document. Invariant: the editor always has one live
160    /// document while it runs (closing the last one sets should_quit).
161    pub fn cur(&self) -> &Document {
162        self.docs
163            .get(self.current())
164            .expect("invariant: current document is live")
165    }
166
167    pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
168        self.doc_mut(self.current())
169    }
170
171    pub fn buf(&self) -> &Buffer {
172        &self.cur().buf
173    }
174
175    pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
176        super::transact::BufferEdit::new(self.cur_mut())
177    }
178
179    /// One document by id — stale ids panic: an id outliving its
180    /// document is a bug, and the generation check is what keeps it
181    /// from silently resolving to the wrong one (0014 wave 2).
182    pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
183        self.docs.get(id).expect("stale document id")
184    }
185
186    pub(crate) fn doc_mut(
187        &mut self,
188        id: strop_core::id::DocumentId,
189    ) -> super::transact::DocumentEdit<'_> {
190        super::transact::DocumentEdit::new(self, id)
191    }
192
193    /// Tests: the first live document's id (the "buffers[0]" of the
194    /// index era).
195    #[cfg(any(test, feature = "test-support"))]
196    pub fn first_doc(&self) -> strop_core::id::DocumentId {
197        self.docs
198            .iter()
199            .next()
200            .map(|(id, _)| id)
201            .expect("test document")
202    }
203
204    /// vim's [No Name] rule: the untouched initial scratch buffer is
205    /// replaced by the first real thing you open. `replacement` is the
206    /// document taking over (0023 §1): panes on the scratch rebind to
207    /// it FIRST — the drop used to strand them (the :vs crash probe).
208    pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
209        // find the pristine scratch (pathless, untouched) wherever it is
210        // — the replacement exists by now, so len() is no longer the
211        // signal (0023: it must fire AFTER the insert so panes can rebind)
212        let scratch = self.docs.iter().find_map(|(id, d)| {
213            let b = &d.buf;
214            (b.path.is_none()
215                && !b.dirty
216                && b.len_bytes() == 0
217                && b.name.is_none()
218                && id != replacement)
219                .then_some(id)
220        });
221        let Some(scratch) = scratch else {
222            return;
223        };
224        if self
225            .pending
226            .prompt()
227            .is_some_and(|prompt| prompt.origin().pane.doc == scratch)
228        {
229            self.cancel_pending();
230        }
231        for pane in &mut self.panes {
232            if pane.doc == scratch {
233                pane.doc = replacement;
234            }
235        }
236        self.lsp_close_document(scratch);
237        self.docs.remove(scratch);
238        self.mru.retain(|&x| x != scratch);
239        if self.view().doc == scratch {
240            self.view_mut().doc = replacement;
241        }
242    }
243
244    /// The active document (derived: the active view's document).
245    #[inline]
246    pub fn current(&self) -> strop_core::id::DocumentId {
247        self.view().doc
248    }
249
250    /// Switch the active view to a document.
251    pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
252        self.cancel_pending();
253        self.cancel_open(strop_core::worker::CancelReason::Superseded);
254        self.focus_epoch += 1;
255        self.view_mut().doc = id;
256        self.touch_mru(id);
257    }
258
259    /// The active view's selections.
260    #[inline]
261    pub fn sels(&self) -> &strop_core::selection::SelectionSet {
262        &self.view().sels
263    }
264
265    #[inline]
266    pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
267        &mut self.view_mut().sels
268    }
269
270    /// The active view's scroll offset.
271    #[inline]
272    /// The active pane's text-area height in rows (render-loop fed).
273    pub fn view_rows(&self) -> usize {
274        self.view_rows
275    }
276
277    pub fn view_top(&self) -> usize {
278        self.view().view_top
279    }
280
281    /// Close the current document; quits when the last one closes.
282    /// Returns false when unsaved changes block the close. Generational
283    /// ids mean no reindexing anywhere (0014 wave 2).
284    pub fn close_buffer(&mut self, force: bool) -> bool {
285        if self.buf().dirty && !force {
286            self.message = "unsaved changes — :q! to force".into();
287            return false;
288        }
289        self.cancel_pending();
290        self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
291        if self.docs.len() == 1 {
292            self.request_session_save();
293        }
294        let closed = self.current();
295        self.revoke_remote_write(closed);
296        self.analysis
297            .forget(super::analysis::AnalysisTarget::Document(closed));
298        self.stop_remote_follow(closed);
299        self.cancel_remote_filter(closed);
300        self.lsp_close_document(closed);
301        self.shell_document_closed(closed);
302        self.revoke_git_requests_for(closed);
303        self.blame_gutters.remove(&closed);
304        self.collections.remove(&closed);
305        self.containers.buffers.remove(&closed);
306        self.containers.entries.remove(&closed);
307        let return_to = self
308            .docs
309            .remove(closed)
310            .and_then(|document| document.return_point().cloned());
311        if self.docs.is_empty() {
312            self.panes.clear();
313            self.active_pane = 0;
314            self.should_quit = true;
315        } else {
316            self.mru.retain(|&x| x != closed);
317            self.generation += 1; // document set changed: old jobs are stale (0011 §2)
318            let next = self.mru.first().copied().unwrap_or_else(|| {
319                self.docs
320                    .iter()
321                    .next()
322                    .map(|(id, _)| id)
323                    .expect("docs non-empty")
324            });
325            for pane in &mut self.panes {
326                if pane.doc == closed {
327                    pane.doc = next;
328                    pane.sels = Default::default();
329                    pane.view_top = 0;
330                    pane.hscroll = strop_core::id::DisplayColumn::new(0);
331                    pane.desired_column = None;
332                }
333            }
334            self.switch_to(next);
335            self.set_head(0);
336            self.view_mut().view_top = 0;
337            self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
338            // a closing surface hands the cursor and view back to the
339            // document it opened from — by id, no index math (0011 §1)
340            if let Some(ret) = return_to {
341                if self.docs.get(ret.buffer).is_some() {
342                    if ret.buffer != self.current() {
343                        self.view_mut().doc = ret.buffer;
344                        self.touch_mru(ret.buffer);
345                    }
346                    self.set_head(ret.cursor.min(self.buf().len_bytes()));
347                    self.view_mut().view_top = ret.view_top;
348                    self.view_mut().hscroll = ret.hscroll;
349                }
350            }
351        }
352        self.lsp_retire_remote_servers();
353        true
354    }
355    /// Any path-backed or scratch document holding unsaved content.
356    pub fn any_dirty(&self) -> bool {
357        self.docs.iter().any(|(_, d)| d.buf.dirty)
358    }
359
360    /// ctrl-c's quit intent (0015): warn once when dirty work exists,
361    /// force on the second press. Returns true when the app may exit.
362    pub fn ctrl_c_quit(&mut self) -> bool {
363        if self.ctrl_c_armed || !self.any_dirty() {
364            return true;
365        }
366        self.ctrl_c_armed = true;
367        self.message = "unsaved changes — ctrl-c again to force-quit".into();
368        false
369    }
370
371    /// Fixture convenience exercises the production request and completion path.
372    #[cfg(any(test, feature = "test-support"))]
373    pub fn open_fixture(
374        &mut self,
375        path: &std::path::Path,
376    ) -> Result<strop_core::id::DocumentId, String> {
377        self.request_open(
378            path.to_owned(),
379            super::io::OpenIntent::Switch { readonly: false },
380        );
381        self.wait_io()?;
382        Ok(self.current())
383    }
384}
385
386#[cfg(test)]
387mod pathbuf_tests {
388    //! 0026: non-UTF-8 filenames (linux names are bytes) work end to end.
389    use super::*;
390    use std::os::unix::ffi::OsStrExt;
391
392    #[test]
393    fn non_utf8_filename_opens_highlights_and_saves() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
396        std::fs::write(&path, "fn main() {}\n").unwrap();
397        let mut e = Editor::new(Buffer::from_text("x\n"));
398        let id = e.open_fixture(&path).expect("opens by bytes");
399        e.switch_to(id);
400        // extension detection works through the OsStr, not a lossy str
401        assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
402            && span.end == 2
403            && span.class == strop_syntax::Class::Keyword));
404        e.feed_text("dd"); // delete the line
405        e.feed_text(":w\r");
406        e.wait_io().unwrap();
407        assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
408    }
409}