Skip to main content

lattice_listing/file_tree/
modes.rs

1//! `file-tree-mode` -- major mode for the file-tree buffer + its
2//! `BufferLocal`-owned per-buffer state (root, entries,
3//! nerd-fonts toggle).
4//!
5//! Lives here (rather than in `lattice-mode`) per the
6//! mode-architecture convention: "a mode lives with the crate
7//! that owns its associated feature." `FileTreeBuffer` lives
8//! in this crate, so the mode + the three pieces of
9//! `file-tree-mode`-owned state live here too.
10//!
11//! ## What the `BufferLocal`s carry
12//!
13//! M.3.2.c.5 made `BufferLocal`s the **single** source of
14//! truth for per-buffer mode-owned state. `FileTreeBuffer`
15//! itself carries only the rendered rope + cursor / scroll;
16//! every piece of "what does this buffer point at"
17//! information lives in the three locals declared below:
18//!
19//! - [`FileTreeRoot`] -- the directory the tree is rooted at.
20//! - [`FileTreeEntries`] -- the flat list of visible entries
21//!   (root + every expanded subdir's children). Mutated
22//!   through the App-side toggle chokepoint that re-renders
23//!   the rope.
24//! - [`FileTreeNerdFonts`] -- whether the rendered rope
25//!   embeds nerd-font glyphs.
26//!
27//! The App reads through these directly; there is no struct
28//! mirror to drift.
29
30use std::path::PathBuf;
31
32use std::sync::{Arc, OnceLock};
33
34use lattice_config::OptionOverrideSet;
35use lattice_core::BufferKind;
36use lattice_core::ui::pane::OpenTarget;
37use lattice_grammar::Effect;
38use lattice_mode::{
39    ActionContext, ActionHandler, ActionHandlerContribution, BufferLocal, CapabilitySet, Keymap,
40    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, ModeRegistry, keymap_entry,
41};
42
43use super::{FileTreeEntry, FileTreeEntryKind};
44
45/// Major mode for file-tree buffers. Read-only contribution
46/// (`ReadOnly = true`); any buffer whose major is
47/// `file-tree-mode` rejects mutating operators.
48pub struct FileTreeMode;
49
50impl FileTreeMode {
51    pub fn mode_id() -> ModeId {
52        ModeId::new("file-tree-mode")
53    }
54}
55
56impl Mode for FileTreeMode {
57    type Guard = ();
58    fn id(&self) -> ModeId {
59        Self::mode_id()
60    }
61    fn kind(&self) -> ModeKind {
62        ModeKind::Major
63    }
64    /// H.2: file-tree buffers (`BufferKind::FileTree`) dispatch to
65    /// this major via the registry's kind index.
66    fn target_buffer_kind(&self) -> Option<BufferKind> {
67        Some(BufferKind::FileTree)
68    }
69    /// `Number = false` is per-major, not per-listing (2026-08-16): a
70    /// tree is a navigation surface and hides line numbers like every
71    /// other tree UI, while oil is an ordinary editable buffer that
72    /// keeps them. It used to sit on the shared
73    /// `directory-listing-mode`, which forced the tree's answer onto
74    /// oil. It lives here now, next to `ReadOnly`, which is per-major
75    /// for the same reason.
76    fn options(&self) -> OptionOverrideSet {
77        lattice_config::overrides! {
78            lattice_config::ReadOnly = true,
79            lattice_config::Number = false,
80        }
81    }
82    fn required_capabilities(&self) -> CapabilitySet {
83        CapabilitySet::empty()
84    }
85    /// 2026-05-26: claim invocation dispatch for file-tree panes
86    /// via `Editor::run_file_tree_invocation`.
87    fn invocation_runner(&self) -> Option<ModeId> {
88        Some(Self::mode_id())
89    }
90    fn keymap(&self) -> Keymap {
91        Keymap::from_entries(file_tree_mode_keymap_entries())
92    }
93    /// LM.4: the navigation/open chord bodies, mode-owned (they were the
94    /// host's `do_file_tree_follow` + the `do_oil_navigate_up` file-tree
95    /// branch, reached through the shared `Help | FileTree` input-gate).
96    /// Each reads the tree's entries from the `ActionContext`'s buffer-locals
97    /// (LM.1) and the row under the cursor, then hands the host an `Effect`
98    /// (the diff/oil pattern). Bound globally at boot; each names its own
99    /// `view` (`ctx.buffer_id`) so many trees stay independent (design §3.2).
100    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
101        // `<CR>`: a directory toggles expansion; a file opens in the current
102        // pane.
103        let follow: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
104            let (path, is_dir, line) = file_tree_entry_at(ctx)?;
105            let view = lattice_core::BufferId(ctx.buffer_id.0 as u32);
106            Some(if is_dir {
107                Effect::FileTreeToggle {
108                    view,
109                    entry_index: line,
110                }
111            } else {
112                Effect::OpenBufferAt {
113                    path: Some(path),
114                    position: lattice_protocol::Position::ZERO,
115                    force: false,
116                    content: None,
117                    activate_minor: None,
118                }
119            })
120        });
121        // `-`: open an oil browser at the row's directory (the directory
122        // itself for a directory row, the file's parent for a file row) —
123        // the file-tree branch of the old `do_oil_navigate_up`.
124        let up: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
125            let (path, is_dir, _line) = file_tree_entry_at(ctx)?;
126            let dir = if is_dir {
127                path
128            } else {
129                path.parent().map(std::path::Path::to_path_buf)?
130            };
131            Some(Effect::OpenOil { dir: Some(dir) })
132        });
133        vec![
134            ActionHandlerContribution {
135                action_name: "action:file-tree-follow",
136                handler: follow,
137            },
138            ActionHandlerContribution {
139                action_name: "action:file-tree-navigate-up",
140                handler: up,
141            },
142            // `<C-s>` / `<C-v>` / `<C-t>`: open the row in a split / vsplit /
143            // tab. A file opens directly; a directory path resolves to
144            // `DoEditOutcome::Directory` in the new pane, opening oil there.
145            file_tree_open_in_target("action:file-tree-follow-split", OpenTarget::Split),
146            file_tree_open_in_target("action:file-tree-follow-vsplit", OpenTarget::VSplit),
147            file_tree_open_in_target("action:file-tree-follow-tab", OpenTarget::Tab),
148        ]
149    }
150    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
151        Box::pin(async { Ok(()) })
152    }
153}
154
155fn file_tree_mode_keymap_entries() -> &'static [KeymapEntry] {
156    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
157    ENTRIES.get_or_init(|| {
158        vec![
159            keymap_entry!(
160                mode: Normal,
161                chord: "<CR>",
162                doc: "Open the row under the cursor: toggle a directory's expansion, or open a file in the current pane.",
163                cmd: "action:file-tree-follow"
164            ),
165            keymap_entry!(
166                mode: Normal,
167                chord: "-",
168                doc: "Open an oil browser at the row's directory.",
169                cmd: "action:file-tree-navigate-up"
170            ),
171            keymap_entry!(
172                mode: Normal,
173                chord: "<C-s>",
174                doc: "Open the row under the cursor in a horizontal split.",
175                cmd: "action:file-tree-follow-split"
176            ),
177            keymap_entry!(
178                mode: Normal,
179                chord: "<C-v>",
180                doc: "Open the row under the cursor in a vertical split.",
181                cmd: "action:file-tree-follow-vsplit"
182            ),
183            keymap_entry!(
184                mode: Normal,
185                chord: "<C-t>",
186                doc: "Open the row under the cursor in a new tab.",
187                cmd: "action:file-tree-follow-tab"
188            ),
189        ]
190    })
191}
192
193/// LM.4: resolve the tree row under the cursor — `(path, is-dir, line)` —
194/// from the `ActionContext`'s buffer-locals. `None` when the buffer carries
195/// no tree state or the cursor is past the last row.
196fn file_tree_entry_at(ctx: &ActionContext<'_>) -> Option<(std::path::PathBuf, bool, u32)> {
197    let entries = ctx.buffer_local::<FileTreeEntries>()?;
198    let line = ctx.cursor.line;
199    let entry = entries.0.get(line as usize)?;
200    let is_dir = matches!(entry.kind, FileTreeEntryKind::Directory { .. });
201    Some((entry.path.clone(), is_dir, line))
202}
203
204/// LM.4: a `<C-s>`/`<C-v>`/`<C-t>` handler that opens the row under the
205/// cursor in `target`. Shared body for the three chords.
206fn file_tree_open_in_target(
207    action_name: &'static str,
208    target: OpenTarget,
209) -> ActionHandlerContribution {
210    let handler: ActionHandler = Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
211        let (path, _is_dir, _line) = file_tree_entry_at(ctx)?;
212        Some(Effect::OpenInTarget {
213            path: Some(path),
214            position: lattice_protocol::Position::ZERO,
215            target,
216        })
217    });
218    ActionHandlerContribution {
219        action_name,
220        handler,
221    }
222}
223
224/// Filesystem path the file-tree buffer is rooted at.
225/// Single source of truth (M.3.2.c.5).
226#[derive(Debug, Clone)]
227pub struct FileTreeRoot(pub PathBuf);
228
229impl BufferLocal for FileTreeRoot {
230    const NAME: &'static str = "file-tree-mode.root";
231    const DOC: &'static str = "Directory the file-tree buffer is rooted at -- the path \
232         the user passed to `:Tree` (or the workspace root for \
233         the default tree).";
234    const OWNER_MODE: &'static str = "file-tree-mode";
235    fn describe(&self) -> String {
236        self.0.display().to_string()
237    }
238}
239
240/// Flat tree-of-entries backing the file-tree buffer.
241/// Each entry carries its depth + expansion state. The rendered
242/// rope is derived from this list -- App-side toggle handlers
243/// mutate the entries through the
244/// [`crate::toggle_entries_at`] helper and re-write this local
245/// + the buffer's rope as one update.
246#[derive(Debug, Clone)]
247pub struct FileTreeEntries(pub Vec<FileTreeEntry>);
248
249impl BufferLocal for FileTreeEntries {
250    const NAME: &'static str = "file-tree-mode.entries";
251    const DOC: &'static str = "Flat list of tree entries (directories + files), each \
252         carrying its depth + expansion state. The file-tree \
253         renderer iterates this in order; the rope content is \
254         derived from it.";
255    const OWNER_MODE: &'static str = "file-tree-mode";
256    fn describe(&self) -> String {
257        format!("{} entries", self.0.len())
258    }
259}
260
261/// Whether the file-tree renders nerd-font icon glyphs inline.
262#[derive(Debug, Clone, Copy)]
263pub struct FileTreeNerdFonts(pub bool);
264
265impl BufferLocal for FileTreeNerdFonts {
266    const NAME: &'static str = "file-tree-mode.nerd-fonts";
267    const DOC: &'static str = "Whether the file-tree buffer's rendered rope embeds \
268         nerd-font icon glyphs alongside file names.";
269    const OWNER_MODE: &'static str = "file-tree-mode";
270    fn describe(&self) -> String {
271        if self.0 { "enabled" } else { "disabled" }.to_string()
272    }
273}
274
275/// Register every `lattice-file-tree`-owned mode against
276/// `registry`. Called from the App's boot path alongside
277/// `lattice_mode::register_foundation_modes` etc. Mirrors
278/// `lattice_oil::register_oil_modes`.
279pub fn register_file_tree_modes(registry: &mut ModeRegistry) {
280    registry
281        .register(FileTreeMode)
282        .expect("file-tree-mode register");
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn file_tree_mode_id_kind_and_options() {
291        assert_eq!(FileTreeMode.id(), FileTreeMode::mode_id());
292        assert_eq!(FileTreeMode::mode_id().as_str(), "file-tree-mode");
293        assert_eq!(FileTreeMode.kind(), ModeKind::Major);
294        // Assert by identity, not by count: a bare count says nothing
295        // about WHICH options are contributed and breaks every time the
296        // set legitimately grows (it did, when `Number` moved here off
297        // the shared minor).
298        let ids: Vec<std::any::TypeId> = FileTreeMode
299            .options()
300            .iter()
301            .map(|o| o.option_type_id)
302            .collect();
303        assert!(
304            ids.contains(&std::any::TypeId::of::<lattice_config::ReadOnly>()),
305            "the tree is read-only"
306        );
307        assert!(
308            ids.contains(&std::any::TypeId::of::<lattice_config::Number>()),
309            "the tree hides line numbers"
310        );
311    }
312
313    #[test]
314    fn buffer_local_metadata_owner_mode() {
315        assert_eq!(<FileTreeRoot as BufferLocal>::OWNER_MODE, "file-tree-mode");
316        assert_eq!(
317            <FileTreeEntries as BufferLocal>::OWNER_MODE,
318            "file-tree-mode"
319        );
320        assert_eq!(
321            <FileTreeNerdFonts as BufferLocal>::OWNER_MODE,
322            "file-tree-mode"
323        );
324        assert_eq!(FileTreeRoot(PathBuf::from("/x")).describe(), "/x");
325        assert_eq!(FileTreeNerdFonts(true).describe(), "enabled");
326        assert_eq!(FileTreeNerdFonts(false).describe(), "disabled");
327    }
328
329    #[test]
330    fn register_file_tree_modes_populates_registry() {
331        let mut registry = ModeRegistry::new();
332        register_file_tree_modes(&mut registry);
333        assert!(registry.is_registered(FileTreeMode::mode_id()));
334    }
335
336    #[test]
337    fn file_tree_mode_binds_navigation_and_open_chords() {
338        use lattice_mode::Mode as _;
339        let km = FileTreeMode.keymap();
340        // LM.4: assert by identity, not count — the set will keep growing.
341        let bound: Vec<(&str, Option<&str>)> =
342            km.entries.iter().map(|e| (e.chord, e.command)).collect();
343        for (chord, cmd) in [
344            ("<CR>", "action:file-tree-follow"),
345            ("-", "action:file-tree-navigate-up"),
346            ("<C-s>", "action:file-tree-follow-split"),
347            ("<C-v>", "action:file-tree-follow-vsplit"),
348            ("<C-t>", "action:file-tree-follow-tab"),
349        ] {
350            assert!(
351                bound.contains(&(chord, Some(cmd))),
352                "file-tree-mode must bind {chord} → {cmd}; got {bound:?}",
353            );
354        }
355    }
356}