Skip to main content

lattice_listing/oil/
modes.rs

1//! `oil-mode` -- major mode for the oil.nvim-style editable
2//! directory listing buffer.
3//!
4//! Lives here (rather than in `lattice-mode`) per the
5//! mode-architecture convention: "a mode lives with the crate
6//! that owns its associated feature." `OilBuffer` lives in
7//! this crate, so the mode + the oil-mode-owned
8//! [`BufferLocal`] state ([`OilDir`]) live here too.
9//!
10//! The mode itself is metadata only: kind = Major, no
11//! contributed options (oil is writable so it
12//! contributes no `ReadOnly` override), no capability
13//! requirements, no-op lifecycle hooks. Behaviour --
14//! navigation, the rope-vs-snapshot diff, the
15//! filesystem-op planner -- lives on `OilBuffer` and the
16//! consumer's App surface.
17
18use std::path::PathBuf;
19use std::sync::{Arc, OnceLock};
20
21use lattice_core::BufferKind;
22use lattice_core::ui::pane::OpenTarget;
23use lattice_grammar::Effect;
24use lattice_mode::{
25    ActionContext, ActionHandler, ActionHandlerContribution, BufferLocal, CapabilitySet, Keymap,
26    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, ModeRegistry, keymap_entry,
27};
28
29/// Major mode for oil-style directory-listing buffers. Any
30/// buffer whose major is `oil-mode` is an `OilBuffer`; the
31/// renderer dispatches accordingly.
32pub struct OilMode;
33
34impl OilMode {
35    pub fn mode_id() -> ModeId {
36        ModeId::new("oil-mode")
37    }
38}
39
40fn oil_mode_keymap_entries() -> &'static [KeymapEntry] {
41    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
42    ENTRIES.get_or_init(|| {
43        vec![
44            keymap_entry!(
45                mode: Normal,
46                chord: "-",
47                doc: "Navigate to the parent directory in the oil buffer.",
48                cmd: "action:oil-navigate-up"
49            ),
50            keymap_entry!(
51                mode: Normal,
52                chord: "<CR>",
53                doc: "Open the entry under the cursor: descend into a directory, or open a file in the current pane.",
54                cmd: "action:oil-follow"
55            ),
56            keymap_entry!(
57                mode: Normal,
58                chord: "<C-s>",
59                doc: "Open the entry under the cursor in a horizontal split.",
60                cmd: "action:oil-follow-split"
61            ),
62            keymap_entry!(
63                mode: Normal,
64                chord: "<C-v>",
65                doc: "Open the entry under the cursor in a vertical split.",
66                cmd: "action:oil-follow-vsplit"
67            ),
68            keymap_entry!(
69                mode: Normal,
70                chord: "<C-t>",
71                doc: "Open the entry under the cursor in a new tab.",
72                cmd: "action:oil-follow-tab"
73            ),
74        ]
75    })
76}
77
78impl Mode for OilMode {
79    type Guard = ();
80    fn id(&self) -> ModeId {
81        Self::mode_id()
82    }
83    fn kind(&self) -> ModeKind {
84        ModeKind::Major
85    }
86    /// H.2: oil buffers (`BufferKind::Oil`) dispatch to this major
87    /// via the registry's kind index.
88    fn target_buffer_kind(&self) -> Option<BufferKind> {
89        Some(BufferKind::Oil)
90    }
91    fn required_capabilities(&self) -> CapabilitySet {
92        CapabilitySet::empty()
93    }
94    /// 2026-05-26: claim invocation dispatch for oil panes via
95    /// `Editor::run_oil_invocation`.
96    fn invocation_runner(&self) -> Option<ModeId> {
97        Some(Self::mode_id())
98    }
99    fn keymap(&self) -> Keymap {
100        Keymap::from_entries(oil_mode_keymap_entries())
101    }
102    /// LM.3: the navigation/open chord bodies, mode-owned (they were the
103    /// host's `do_oil_follow` / `do_oil_navigate_up` + the `BufferKind::Oil`
104    /// input-gate). Each reads the oil buffer's dir + snapshot from the
105    /// `ActionContext`'s buffer-locals (LM.1) and the entry under the cursor,
106    /// then hands the host an `Effect` (the diff-mode pattern): the mode owns
107    /// the *decision*, the host owns the *apply*. Bound globally at boot by
108    /// `register_mode_action_handlers` — oil-mode can be active on many
109    /// buffers at once, and each handler names its own `view`
110    /// (`ctx.buffer_id`), so many oil buffers stay independent (design §3.2).
111    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
112        // `<CR>`: a directory re-lists in place; a file opens in the current
113        // pane.
114        let follow: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
115            let (dir, name, is_dir) = oil_entry_at(ctx)?;
116            let view = lattice_core::BufferId(ctx.buffer_id.0 as u32);
117            let target = dir.join(&name);
118            Some(if is_dir {
119                Effect::OilNavigate {
120                    view,
121                    dir: target,
122                    focus: None,
123                }
124            } else {
125                Effect::OpenBufferAt {
126                    path: Some(target),
127                    position: lattice_protocol::Position::ZERO,
128                    force: false,
129                    content: None,
130                    activate_minor: None,
131                }
132            })
133        });
134        // `-`: re-list to the parent, landing the cursor on the directory we
135        // stepped out of (oil.nvim's round-trip).
136        let up: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
137            let dir = ctx.buffer_local::<OilDir>()?.0.clone();
138            let parent = dir.parent()?.to_path_buf();
139            let view = lattice_core::BufferId(ctx.buffer_id.0 as u32);
140            let focus = dir.file_name().map(|n| n.to_string_lossy().into_owned());
141            Some(Effect::OilNavigate {
142                view,
143                dir: parent,
144                focus,
145            })
146        });
147        vec![
148            ActionHandlerContribution {
149                action_name: "action:oil-follow",
150                handler: follow,
151            },
152            ActionHandlerContribution {
153                action_name: "action:oil-navigate-up",
154                handler: up,
155            },
156            // `<C-s>` / `<C-v>` / `<C-t>`: open the entry in a split / vsplit /
157            // tab. A file opens directly; a directory path resolves to
158            // `DoEditOutcome::Directory` in the new pane, which opens oil there
159            // (`handle_do_edit_outcome`), so the same effect serves both.
160            oil_open_in_target("action:oil-follow-split", OpenTarget::Split),
161            oil_open_in_target("action:oil-follow-vsplit", OpenTarget::VSplit),
162            oil_open_in_target("action:oil-follow-tab", OpenTarget::Tab),
163        ]
164    }
165    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
166        Box::pin(async { Ok(()) })
167    }
168}
169
170/// LM.3: resolve the oil entry under the cursor — `(oil dir, entry name,
171/// is-dir)` — from the `ActionContext`'s buffer-locals. `None` when the
172/// buffer carries no oil state or the cursor is past the last row, which a
173/// handler treats as "nothing to open".
174fn oil_entry_at(ctx: &ActionContext<'_>) -> Option<(PathBuf, String, bool)> {
175    let dir = ctx.buffer_local::<OilDir>()?.0.clone();
176    let snapshot = ctx.buffer_local::<OilSnapshotLocal>()?;
177    let entry = snapshot
178        .0
179        .snapshot_entries()
180        .get(ctx.cursor.line as usize)?;
181    Some((dir, entry.name.clone(), entry.is_dir))
182}
183
184/// LM.3: a `<C-s>`/`<C-v>`/`<C-t>` handler that opens the entry under the
185/// cursor in `target`. Shared body for the three chords.
186fn oil_open_in_target(action_name: &'static str, target: OpenTarget) -> ActionHandlerContribution {
187    let handler: ActionHandler = Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
188        let (dir, name, _is_dir) = oil_entry_at(ctx)?;
189        Some(Effect::OpenInTarget {
190            path: Some(dir.join(&name)),
191            position: lattice_protocol::Position::ZERO,
192            target,
193        })
194    });
195    ActionHandlerContribution {
196        action_name,
197        handler,
198    }
199}
200
201/// `BufferLocal` carrying the filesystem path the oil
202/// buffer's listing represents (M.3.2.c.3 mirror of
203/// `OilBuffer::dir`). Renderers / writers read through this
204/// rather than poking `OilBuffer::dir` directly so the canonical
205/// "what directory does this oil buffer represent" lookup is
206/// uniform with the rest of the mode-owned per-buffer state.
207#[derive(Debug, Clone)]
208pub struct OilDir(pub PathBuf);
209
210/// DL.5: the directory state `:w` diffs against.
211///
212/// It used to live on `OilBuffer` alongside the rope. The rope is an
213/// actor-backed Document now, so the snapshot moves here — the same
214/// place `OilDir` already lived, and the same shape the file tree has
215/// used for its entries all along.
216#[derive(Debug, Clone, Default)]
217pub struct OilSnapshotLocal(pub super::OilSnapshot);
218
219impl BufferLocal for OilSnapshotLocal {
220    const NAME: &'static str = "oil-mode.snapshot";
221    const DOC: &'static str = "Directory entries as of open or the last successful `:w`. \
222         `:w` diffs the buffer's text against this to derive the renames, \
223         deletes and creates to execute.";
224    const OWNER_MODE: &'static str = "oil-mode";
225    fn describe(&self) -> String {
226        format!("{} entries", self.0.snapshot_entries().len())
227    }
228}
229
230impl BufferLocal for OilDir {
231    const NAME: &'static str = "oil-mode.dir";
232    const DOC: &'static str = "Directory the oil buffer's editable listing represents. \
233         Diff-on-:write applies filesystem ops relative to this \
234         path; status line shows it.";
235    const OWNER_MODE: &'static str = "oil-mode";
236    fn describe(&self) -> String {
237        self.0.display().to_string()
238    }
239}
240
241/// Register every `lattice-oil`-owned mode against `registry`.
242/// Called from the App's boot path alongside
243/// `lattice_mode::register_foundation_modes`,
244/// `lattice_syntax::register_language_modes`, and
245/// `lattice_lsp::register_lsp_log_modes`. Mirrors the same
246/// per-feature-crate registration pattern.
247pub fn register_oil_modes(registry: &mut ModeRegistry) {
248    registry.register(OilMode).expect("oil-mode register");
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn oil_mode_id_and_kind() {
257        assert_eq!(OilMode.id(), OilMode::mode_id());
258        assert_eq!(OilMode::mode_id().as_str(), "oil-mode");
259        assert_eq!(OilMode.kind(), ModeKind::Major);
260    }
261
262    #[test]
263    fn oil_dir_buffer_local_owner_mode_is_oil_mode() {
264        assert_eq!(<OilDir as BufferLocal>::OWNER_MODE, "oil-mode");
265        assert_eq!(<OilDir as BufferLocal>::NAME, "oil-mode.dir");
266        let d = OilDir(PathBuf::from("/tmp/x"));
267        assert_eq!(d.describe(), "/tmp/x");
268    }
269
270    #[test]
271    fn register_oil_modes_populates_registry() {
272        let mut registry = ModeRegistry::new();
273        register_oil_modes(&mut registry);
274        assert!(registry.is_registered(OilMode::mode_id()));
275    }
276
277    #[test]
278    fn oil_mode_keymap_binds_navigation_and_open_chords() {
279        use lattice_mode::Mode as _;
280        let km = OilMode.keymap();
281        // Assert by identity, not count: LM.3 added `<CR>` + the three
282        // open-in-target chords beside `-`, and the set will keep growing.
283        let bound: Vec<(&str, Option<&str>)> =
284            km.entries.iter().map(|e| (e.chord, e.command)).collect();
285        for (chord, cmd) in [
286            ("-", "action:oil-navigate-up"),
287            ("<CR>", "action:oil-follow"),
288            ("<C-s>", "action:oil-follow-split"),
289            ("<C-v>", "action:oil-follow-vsplit"),
290            ("<C-t>", "action:oil-follow-tab"),
291        ] {
292            assert!(
293                bound.contains(&(chord, Some(cmd))),
294                "oil-mode must bind {chord} → {cmd}; got {bound:?}",
295            );
296        }
297    }
298
299    #[test]
300    fn oil_mode_keymap_entry_chord_and_command() {
301        use lattice_mode::Mode as _;
302        let km = OilMode.keymap();
303        let e = &km.entries[0];
304        assert_eq!(e.chord, "-");
305        assert_eq!(e.command, Some("action:oil-navigate-up"));
306    }
307}