lattice_listing/file_tree/
modes.rs1use 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
45pub 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 fn target_buffer_kind(&self) -> Option<BufferKind> {
67 Some(BufferKind::FileTree)
68 }
69 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 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 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
101 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 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 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
193fn 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
204fn 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#[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#[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#[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
275pub 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 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 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}