vorto 0.15.0

A Vim-flavored modal terminal editor with batteries included: tree-sitter, LSP, fuzzy pickers, vim-surround, multi-cursor, and optional Copilot.
//! Compressed snapshots of inactive documents.
//!
//! Each entry in [`super::App::sleeping`] holds the full state of a
//! *document* whose ref is no longer shown in any pane (lines,
//! undo/redo history, dirty flag, …) so a `<space>b` round-trip
//! preserves unsaved edits. Cursor / mode are per-pane *session* state
//! now (they live on [`Editor`], not [`Buffer`]), so a document that's
//! shown nowhere has no live cursor — on reopen a fresh `Editor` with
//! a default cursor is created.
//!
//! To keep memory bounded — undo history alone can easily duplicate the
//! source 200x — we deflate the line content lazily: if the *total* raw
//! byte count for a document exceeds [`COMPRESS_THRESHOLD`], we compress
//! every line vector inside it. Otherwise we leave the content raw to
//! avoid the per-allocation overhead on tiny scratch files.
//!
//! `dirty` and the path stay uncompressed regardless so the picker and
//! `:q`-time dirty check can read them without paying for a thaw.

use std::io::{Read, Write};
use std::path::PathBuf;

use flate2::Compression;
use flate2::read::DeflateDecoder;
use flate2::write::DeflateEncoder;

use crate::editor::{Buffer, Cursor, Snapshot};

/// Minimum raw byte count (main + all undo + all redo) for a
/// sleeping buffer's line content to be compressed. Below this we
/// keep things raw — deflate carries ~15 bytes of framing overhead
/// and 200 tiny snapshots can still sum to a meaningful number, so
/// the threshold is on the *buffer total*, not per-snapshot.
const COMPRESS_THRESHOLD: usize = 4 * 1024;

/// Lines payload, either kept raw or stored as a deflate blob with
/// `\n` separators. The two variants are interchangeable through
/// [`Lines::thaw`] / [`Lines::freeze_raw`] / [`Lines::freeze_zip`].
#[derive(Debug)]
pub(super) enum Lines {
    Raw(Vec<String>),
    Compressed(Vec<u8>),
}

impl Lines {
    fn freeze_raw(lines: Vec<String>) -> Self {
        Lines::Raw(lines)
    }

    fn freeze_zip(lines: Vec<String>) -> Self {
        let joined = lines.join("\n");
        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
        enc.write_all(joined.as_bytes())
            .expect("deflate write to Vec cannot fail");
        let blob = enc.finish().expect("deflate finish to Vec cannot fail");
        Lines::Compressed(blob)
    }

    fn thaw(self) -> Vec<String> {
        match self {
            Lines::Raw(v) => v,
            Lines::Compressed(blob) => {
                let mut dec = DeflateDecoder::new(blob.as_slice());
                let mut s = String::new();
                dec.read_to_string(&mut s)
                    .expect("deflate decode of a self-produced blob cannot fail");
                s.split('\n').map(|s| s.to_string()).collect()
            }
        }
    }

    /// Sum of the raw byte sizes of all lines (excluding `\n`
    /// separators). Used by the threshold check; we approximate
    /// separator bytes by adding `len - 1` once in the caller.
    fn raw_size(lines: &[String]) -> usize {
        lines.iter().map(|l| l.len()).sum()
    }
}

#[derive(Debug)]
pub(super) struct FrozenSnapshot {
    lines: Lines,
    cursor: Cursor,
    extra_cursors: Vec<Cursor>,
    dirty: bool,
}

/// All the state we hold for a sleeping document. Built by
/// [`SleepingBuffer::freeze`] and consumed by
/// [`SleepingBuffer::thaw`].
#[derive(Debug)]
pub struct SleepingBuffer {
    lines: Lines,
    path: Option<PathBuf>,
    pub dirty: bool,
    yank: String,
    version: u64,
    scroll: usize,
    col_scroll: usize,
    undo: Vec<FrozenSnapshot>,
    redo: Vec<FrozenSnapshot>,
    /// Preserved across freeze/thaw so the external-edit guard on
    /// `:w` still rejects a clobber when the user comes back to a
    /// buffer that was stashed while another tool rewrote the file.
    disk_meta: Option<crate::editor::FileMeta>,
    /// The merge ancestor (last-seen disk content), preserved so a dirty
    /// buffer that slept through an external edit can still three-way
    /// merge on thaw under `autoreload = "merge"` instead of falling back
    /// to a wholesale replace. Frozen with the same raw/zip strategy as
    /// `lines`.
    disk_base: Option<Lines>,
}

impl SleepingBuffer {
    pub fn freeze(b: Buffer) -> Self {
        // Decide once, for the whole buffer: if the total raw byte
        // count is above the threshold, compress everything;
        // otherwise keep everything raw. This catches the "lots of
        // sub-threshold undo entries that collectively add up"
        // pattern that a per-snapshot threshold would miss.
        let main_size = Lines::raw_size(&b.lines);
        let undo_size: usize = b.undo_stack.iter().map(|s| Lines::raw_size(&s.lines)).sum();
        let redo_size: usize = b.redo_stack.iter().map(|s| Lines::raw_size(&s.lines)).sum();
        let disk_base_size = b.disk_base.as_ref().map_or(0, |d| Lines::raw_size(d));
        let compress = main_size + undo_size + redo_size + disk_base_size > COMPRESS_THRESHOLD;
        let freeze = |lines: Vec<String>| -> Lines {
            if compress {
                Lines::freeze_zip(lines)
            } else {
                Lines::freeze_raw(lines)
            }
        };

        let undo: Vec<FrozenSnapshot> = b
            .undo_stack
            .into_iter()
            .map(|s| FrozenSnapshot {
                lines: freeze(s.lines),
                cursor: s.cursor,
                extra_cursors: s.extra_cursors,
                dirty: s.dirty,
            })
            .collect();
        let redo: Vec<FrozenSnapshot> = b
            .redo_stack
            .into_iter()
            .map(|s| FrozenSnapshot {
                lines: freeze(s.lines),
                cursor: s.cursor,
                extra_cursors: s.extra_cursors,
                dirty: s.dirty,
            })
            .collect();

        SleepingBuffer {
            lines: freeze(b.lines),
            path: b.path,
            dirty: b.dirty,
            yank: b.yank,
            version: b.version,
            scroll: b.scroll.get(),
            col_scroll: b.col_scroll.get(),
            undo,
            redo,
            disk_meta: b.disk_meta,
            disk_base: b.disk_base.map(&freeze),
        }
    }

    /// Borrow the current path. `None` for scratch buffers.
    pub fn path(&self) -> Option<&PathBuf> {
        self.path.as_ref()
    }

    /// Overwrite the path of a sleeping buffer. Used when the explorer
    /// renames or moves a file underneath an inactive buffer so the
    /// next thaw lands the buffer at the new on-disk location.
    pub fn set_path(&mut self, path: Option<PathBuf>) {
        self.path = path;
    }

    /// Decompress the main line payload without consuming the
    /// snapshot. Used by the fuzzy preview pane so a diagnostic
    /// pointing at a sleeping buffer (e.g. `:e new_file.rs` after
    /// the user switched to a different file) renders from the
    /// in-memory copy instead of falling through to a disk read
    /// that may not have the buffer's unsaved edits — or, for a
    /// `:e` of a path that doesn't exist yet, no file at all.
    /// Allocates on every call; the preview pane re-renders rarely
    /// enough that a tiny re-decompress is cheaper than a long-lived
    /// thawed cache.
    pub fn peek_lines(&self) -> Vec<String> {
        match &self.lines {
            Lines::Raw(v) => v.clone(),
            Lines::Compressed(blob) => {
                let mut dec = DeflateDecoder::new(blob.as_slice());
                let mut s = String::new();
                dec.read_to_string(&mut s)
                    .expect("deflate decode of a self-produced blob cannot fail");
                s.split('\n').map(|s| s.to_string()).collect()
            }
        }
    }

    pub fn thaw(self) -> Buffer {
        let mut b = Buffer::new();
        b.lines = self.lines.thaw();
        b.path = self.path;
        b.dirty = self.dirty;
        b.yank = self.yank;
        b.version = self.version;
        b.scroll.set(self.scroll);
        b.col_scroll.set(self.col_scroll);
        b.disk_meta = self.disk_meta;
        b.disk_base = self.disk_base.map(|d| d.thaw());
        b.undo_stack = self
            .undo
            .into_iter()
            .map(|s| Snapshot {
                lines: s.lines.thaw(),
                cursor: s.cursor,
                extra_cursors: s.extra_cursors,
                dirty: s.dirty,
            })
            .collect();
        b.redo_stack = self
            .redo
            .into_iter()
            .map(|s| Snapshot {
                lines: s.lines.thaw(),
                cursor: s.cursor,
                extra_cursors: s.extra_cursors,
                dirty: s.dirty,
            })
            .collect();
        // Engine, viewport_height, and the VCS base are intentionally
        // left as their defaults — the highlighter is rebuilt by a
        // worker on restore, the UI re-publishes viewport_height on the
        // next draw, and the VCS base is re-fetched off-thread by
        // `App::spawn_vcs_worker` so a `git show` round-trip can't block
        // the restore. An external commit while the buffer slept thus
        // still shows up in the gutter, just a frame later.
        //
        // Cursor / mode are NOT restored — they're per-pane session
        // state now, and a document shown in no pane carries no cursor.
        // The caller wraps the thawed document in a fresh `Editor`
        // (cursor at origin, Normal mode).
        b
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn buf_from(lines: &[&str]) -> Buffer {
        let mut b = Buffer::new();
        b.lines = lines.iter().map(|s| s.to_string()).collect();
        b
    }

    #[test]
    fn freeze_thaw_roundtrip_preserves_lines() {
        let mut b = buf_from(&["hello", "world", "foo bar baz"]);
        b.dirty = true;
        b.yank = "yanked".to_string();
        b.version = 42;
        // The merge ancestor must survive the round-trip so `autoreload =
        // "merge"` can still three-way merge a buffer that slept through an
        // external edit, rather than replacing it wholesale.
        b.disk_base = Some(vec!["hello".to_string(), "world".to_string()]);

        let frozen = SleepingBuffer::freeze(b);
        let thawed = frozen.thaw();
        assert_eq!(thawed.lines, vec!["hello", "world", "foo bar baz"]);
        assert!(thawed.dirty);
        assert_eq!(thawed.yank, "yanked");
        assert_eq!(thawed.version, 42);
        assert_eq!(
            thawed.disk_base.as_deref(),
            Some(&["hello".to_string(), "world".to_string()][..])
        );
    }

    #[test]
    fn tiny_buffer_stays_uncompressed() {
        // Below the 4KB threshold — should be Raw, not Compressed.
        let b = buf_from(&["x"]);
        let frozen = SleepingBuffer::freeze(b);
        assert!(matches!(frozen.lines, Lines::Raw(_)));
    }

    #[test]
    fn over_threshold_compresses_everything_even_tiny_snapshots() {
        // A buffer whose *main* lines fit below the threshold but
        // whose undo stack adds up past it — every line vector must
        // end up Compressed, including the small main one.
        let mut b = buf_from(&["short main"]);
        // Push 200 small snapshots (200 × ~50B = 10KB > 4KB).
        for i in 0..200 {
            b.undo_stack.push(Snapshot {
                lines: vec![format!("snapshot {i:03} of fifty-something bytes per row")],
                cursor: Cursor::default(),
                extra_cursors: Vec::new(),
                dirty: false,
            });
        }
        let frozen = SleepingBuffer::freeze(b);
        assert!(matches!(frozen.lines, Lines::Compressed(_)));
        for s in &frozen.undo {
            assert!(matches!(s.lines, Lines::Compressed(_)));
        }
    }

    #[test]
    fn dirty_flag_visible_without_thaw() {
        let mut b = buf_from(&["x"]);
        b.dirty = true;
        let frozen = SleepingBuffer::freeze(b);
        // Cheap to read; no decompression needed even when compressed.
        assert!(frozen.dirty);
    }
}