Skip to main content

lattice_listing/oil/
mod.rs

1//! Flat directory listing buffer — oil.nvim-style editable view.
2//!
3//! The buffer's text is one bare filename per line. Bare is
4//! load-bearing: `apply()` diffs that text against the open-time
5//! snapshot to derive the renames / deletes / creates to execute, so
6//! anything decorative in it would be read as a filename. Icons are
7//! leading virtual text published by `directory-listing-mode` (DL.3b)
8//! for exactly this reason.
9//!
10//! DL.5: the buffer itself is an ordinary actor-backed Document — oil
11//! is writable, so its edits take the normal document path with no
12//! bespoke rope-mutation detour, and it paints through the shared
13//! compose path like every other kind.
14//!
15//! ## Where does "the dir this oil buffer represents" live?
16//!
17//! Not on the snapshot. M.3.2.c.5 retired the struct-stored `dir`
18//! field; the canonical answer is the [`OilDir`] `BufferLocal`
19//! owned by `oil-mode`. The App reads it from
20//! `buffer_locals[id].get::<OilDir>()` and passes it into the
21//! `OilBuffer` methods that need it ([`OilBuffer::open`],
22//! [`OilBuffer::reload`], [`OilBuffer::apply`]) as an explicit
23//! `&Path` parameter.
24//!
25//! That design forces a single source of truth (the buffer-local)
26//! and makes the per-buffer mode-owned state uniform across every
27//! buffer kind: a reader does `buffer_locals[id].get::<T>()` and
28//! never branches on `BufferKind`. `:describe-buffer` enumerates
29//! every contributed local through the same path. Forgetting to
30//! re-mirror state after a mutation -- the class of bug that
31//! produced "navigate-up corrupts paths" -- is impossible by
32//! construction because the buffer-local IS the state. There's
33//! nothing to mirror.
34
35pub mod modes;
36
37pub use modes::{OilDir, OilMode, register_oil_modes};
38
39use std::path::Path;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct OilEntry {
43    pub name: String,
44    pub is_dir: bool,
45}
46
47/// The directory state an oil buffer diffs against: the entries as
48/// they were at open, or at the last successful `:w`.
49///
50/// DL.5: this used to be `OilBuffer`, carrying the rope, a cursor and
51/// a scroll alongside the snapshot. The rope moved into an
52/// actor-backed Document (the buffer *is* one now), and the cursor /
53/// scroll were archival duplicates of the pane's that the hot path
54/// never read — their own doc comment said reading them was unsafe.
55/// What is left is the only thing oil genuinely owns: what the
56/// directory looked like, so `:w` can tell what the user changed.
57///
58/// The current text is passed in rather than held, because the
59/// Document owns it. That is also what keeps the rope bare filenames:
60/// icons are virtual text, so the diff sees exactly what the user
61/// typed.
62///
63/// Does NOT hold the directory path — that lives in the [`OilDir`]
64/// `BufferLocal`.
65#[derive(Debug, Clone, Default)]
66pub struct OilSnapshot {
67    entries: Vec<OilEntry>,
68}
69
70impl OilSnapshot {
71    /// Read `dir` into a fresh snapshot.
72    pub fn open(dir: &Path) -> std::io::Result<Self> {
73        Ok(Self {
74            entries: read_dir_entries(dir)?,
75        })
76    }
77
78    /// Re-read `dir`, replacing the snapshot.
79    pub fn reload(&mut self, dir: &Path) -> std::io::Result<()> {
80        self.entries = read_dir_entries(dir)?;
81        Ok(())
82    }
83
84    /// True when `current_text` differs from what the snapshot renders
85    /// to — i.e. the user edited the listing.
86    pub fn is_dirty(&self, current_text: &str) -> bool {
87        current_text != render_to_text(&self.entries)
88    }
89
90    /// Names from the snapshot (used by apply and tests).
91    pub fn snapshot_names(&self) -> Vec<&str> {
92        self.entries.iter().map(|e| e.name.as_str()).collect()
93    }
94
95    /// Snapshot entries (used by renderer).
96    pub fn snapshot_entries(&self) -> &[OilEntry] {
97        &self.entries
98    }
99
100    /// Entry at `line` in the snapshot.
101    pub fn entry_at_line(&self, line: u32) -> Option<&OilEntry> {
102        self.entries.get(line as usize)
103    }
104
105    /// Diff rope against snapshot and execute filesystem operations
106    /// relative to `dir`. Order: renames → deletes → creates.
107    /// On error: stops, returns the error (caller echoes it).
108    /// On success: refreshes snapshot to new disk state.
109    ///
110    /// `dir` is supplied by the caller from
111    /// `buffer_locals[id].get::<OilDir>()`. Passing it explicitly
112    /// instead of storing on the buffer eliminates the class of
113    /// sync bug where post-navigate state could drift (the
114    /// buffer-local IS the state).
115    pub fn apply(&mut self, dir: &Path, current_text: &str) -> std::io::Result<()> {
116        let current_names: Vec<String> = current_text
117            .lines()
118            .map(|l| l.trim().to_owned())
119            .filter(|l| !l.is_empty())
120            .collect();
121
122        let snap_names: std::collections::HashSet<&str> =
123            self.entries.iter().map(|e| e.name.as_str()).collect();
124        let curr_set: std::collections::HashSet<&str> =
125            current_names.iter().map(|s| s.as_str()).collect();
126
127        let deleted: Vec<&OilEntry> = self
128            .entries
129            .iter()
130            .filter(|e| !curr_set.contains(e.name.as_str()))
131            .collect();
132
133        let created: Vec<&str> = current_names
134            .iter()
135            .map(|s| s.as_str())
136            .filter(|name| !snap_names.contains(*name))
137            .collect();
138
139        // Rename heuristic: exactly 1 delete + 1 create → use fs::rename
140        if deleted.len() == 1 && created.len() == 1 {
141            let from = dir.join(&deleted[0].name);
142            let to = dir.join(created[0]);
143            std::fs::rename(&from, &to)?;
144        } else {
145            for entry in &deleted {
146                let path = dir.join(&entry.name);
147                if entry.is_dir {
148                    std::fs::remove_dir_all(&path)?;
149                } else {
150                    std::fs::remove_file(&path)?;
151                }
152            }
153            for name in &created {
154                let clean = name.trim_end_matches('/');
155                let path = dir.join(clean);
156                if name.ends_with('/') {
157                    std::fs::create_dir_all(&path)?;
158                } else {
159                    std::fs::File::create(&path)?;
160                }
161            }
162        }
163
164        // Refresh snapshot from disk.
165        // Refresh from disk so the next diff is against reality.
166        self.entries = read_dir_entries(dir)?;
167        Ok(())
168    }
169}
170
171/// Read a directory's entries sorted dirs-first then alpha.
172fn read_dir_entries(dir: &Path) -> std::io::Result<Vec<OilEntry>> {
173    // DL.7: one shared read + ordering rule (`dir::read_dir_sorted`).
174    // Oil keeps only the name — its rope is bare filenames, because
175    // `:w` diffs that text back into renames.
176    Ok(crate::dir::read_dir_sorted(dir)?
177        .into_iter()
178        .map(|e| OilEntry {
179            name: e.name,
180            is_dir: e.is_dir,
181        })
182        .collect())
183}
184
185/// Render snapshot entries to a rope — one bare name per line, no icons.
186/// The listing text: one bare filename per line, dirs-first alpha.
187///
188/// Bare is load-bearing — `:w` diffs this against the snapshot to
189/// derive renames, so anything decorative in here would be read as a
190/// filename. Icons are virtual text for exactly this reason.
191pub fn render_to_text(entries: &[OilEntry]) -> String {
192    entries
193        .iter()
194        .map(|e| e.name.as_str())
195        .collect::<Vec<_>>()
196        .join("\n")
197}
198
199/// Project the snapshot into the shape `directory-listing-mode` reads.
200/// `icon_byte` is 0 — oil rows are bare names, so the icon leads.
201pub fn listing_entries(
202    dir: &std::path::Path,
203    entries: &[OilEntry],
204) -> Vec<crate::listing_mode::ListingEntry> {
205    entries
206        .iter()
207        .map(|e| crate::listing_mode::ListingEntry {
208            path: dir.join(&e.name),
209            is_dir: e.is_dir,
210            icon_byte: 0,
211            // Oil's row IS the bare name (`render_to_text`), so the name
212            // spans the whole line.
213            name_byte_len: e.name.len() as u32,
214        })
215        .collect()
216}
217
218#[cfg(test)]
219mod tests {
220    #![allow(clippy::unwrap_used)]
221    use super::*;
222    use std::path::PathBuf;
223
224    fn temp_dir() -> PathBuf {
225        use std::sync::atomic::{AtomicU64, Ordering};
226        static N: AtomicU64 = AtomicU64::new(0);
227        let dir = std::env::temp_dir().join(format!(
228            "lattice-oil-{}-{}",
229            std::process::id(),
230            N.fetch_add(1, Ordering::Relaxed)
231        ));
232        std::fs::create_dir_all(&dir).unwrap();
233        dir
234    }
235
236    #[test]
237    fn open_lists_dirs_first_then_files_alpha() {
238        let dir = temp_dir();
239        std::fs::write(dir.join("z.txt"), "").unwrap();
240        std::fs::write(dir.join("a.txt"), "").unwrap();
241        std::fs::create_dir(dir.join("sub")).unwrap();
242        let oil = OilSnapshot::open(&dir).unwrap();
243        assert_eq!(oil.snapshot_names(), vec!["sub", "a.txt", "z.txt"]);
244        std::fs::remove_dir_all(dir).ok();
245    }
246
247    #[test]
248    fn rope_contains_bare_names_only() {
249        let dir = temp_dir();
250        std::fs::write(dir.join("main.rs"), "").unwrap();
251        let oil = OilSnapshot::open(&dir).unwrap();
252        let text = render_to_text(oil.snapshot_entries());
253        assert_eq!(text.trim(), "main.rs");
254        assert!(!text.contains("󱘗"), "icon should not be in rope");
255        std::fs::remove_dir_all(dir).ok();
256    }
257
258    #[test]
259    fn reload_replaces_listing() {
260        let dir = temp_dir();
261        let sub = dir.join("sub");
262        std::fs::create_dir(&sub).unwrap();
263        std::fs::write(sub.join("inner.rs"), "").unwrap();
264        let mut oil = OilSnapshot::open(&dir).unwrap();
265        oil.reload(&sub).unwrap();
266        assert_eq!(oil.snapshot_names(), vec!["inner.rs"]);
267        std::fs::remove_dir_all(dir).ok();
268    }
269
270    #[test]
271    fn navigate_up_via_caller_computed_parent() {
272        // The `navigate_up` method was retired in M.3.2.c.5
273        // -- the caller computes the parent from the OilDir
274        // buffer-local and calls reload. This test pins the
275        // shape: starting in `sub`, reload at `dir` (its
276        // parent) yields `dir`'s entries.
277        let dir = temp_dir();
278        let sub = dir.join("sub");
279        std::fs::create_dir(&sub).unwrap();
280        std::fs::write(dir.join("a.txt"), "").unwrap();
281        let mut oil = OilSnapshot::open(&sub).unwrap();
282        // Caller does: parent = sub.parent(); oil.reload(parent).
283        let parent = sub.parent().expect("sub has parent").to_path_buf();
284        oil.reload(&parent).unwrap();
285        assert!(oil.snapshot_names().contains(&"a.txt"));
286        std::fs::remove_dir_all(dir).ok();
287    }
288
289    #[test]
290    fn is_dirty_false_when_unchanged() {
291        let dir = temp_dir();
292        std::fs::write(dir.join("a.txt"), "").unwrap();
293        let oil = OilSnapshot::open(&dir).unwrap();
294        assert!(!oil.is_dirty(&render_to_text(oil.snapshot_entries())));
295        std::fs::remove_dir_all(dir).ok();
296    }
297
298    #[test]
299    fn apply_renames_single_file() {
300        let dir = temp_dir();
301        std::fs::write(dir.join("old.txt"), "content").unwrap();
302        let mut oil = OilSnapshot::open(&dir).unwrap();
303        // The user's edited listing text.
304        let text = "new.txt".to_string();
305        oil.apply(&dir, &text).unwrap();
306        assert!(
307            dir.join("new.txt").exists(),
308            "new.txt should exist after rename"
309        );
310        assert!(
311            !dir.join("old.txt").exists(),
312            "old.txt should not exist after rename"
313        );
314        std::fs::remove_dir_all(dir).ok();
315    }
316
317    #[test]
318    fn apply_deletes_removed_lines() {
319        let dir = temp_dir();
320        std::fs::write(dir.join("keep.txt"), "").unwrap();
321        std::fs::write(dir.join("gone.txt"), "").unwrap();
322        let mut oil = OilSnapshot::open(&dir).unwrap();
323        let text = render_to_text(oil.snapshot_entries());
324        let new_text = text
325            .lines()
326            .filter(|l| *l != "gone.txt")
327            .collect::<Vec<_>>()
328            .join("\n");
329        oil.apply(&dir, &new_text).unwrap();
330        assert!(!dir.join("gone.txt").exists());
331        assert!(dir.join("keep.txt").exists());
332        std::fs::remove_dir_all(dir).ok();
333    }
334
335    #[test]
336    fn apply_creates_new_file_lines() {
337        let dir = temp_dir();
338        std::fs::write(dir.join("existing.txt"), "").unwrap();
339        let mut oil = OilSnapshot::open(&dir).unwrap();
340        let mut text = render_to_text(oil.snapshot_entries());
341        text.push_str("\nnewfile.txt");
342        let text = text;
343        oil.apply(&dir, &text).unwrap();
344        assert!(dir.join("newfile.txt").exists());
345        std::fs::remove_dir_all(dir).ok();
346    }
347
348    #[test]
349    fn apply_handles_multiple_deletes_and_creates() {
350        let dir = temp_dir();
351        std::fs::write(dir.join("a.txt"), "").unwrap();
352        std::fs::write(dir.join("b.txt"), "").unwrap();
353        let mut oil = OilSnapshot::open(&dir).unwrap();
354        let text = "c.txt\nd.txt".to_string();
355        oil.apply(&dir, &text).unwrap();
356        assert!(!dir.join("a.txt").exists());
357        assert!(!dir.join("b.txt").exists());
358        assert!(dir.join("c.txt").exists());
359        assert!(dir.join("d.txt").exists());
360        std::fs::remove_dir_all(dir).ok();
361    }
362
363    #[test]
364    fn apply_refreshes_snapshot_on_success() {
365        let dir = temp_dir();
366        std::fs::write(dir.join("a.txt"), "").unwrap();
367        let mut oil = OilSnapshot::open(&dir).unwrap();
368        let text = "b.txt".to_string();
369        oil.apply(&dir, &text).unwrap();
370        assert_eq!(oil.snapshot_names(), vec!["b.txt"]);
371        assert!(!oil.is_dirty(&render_to_text(oil.snapshot_entries())));
372        std::fs::remove_dir_all(dir).ok();
373    }
374}