Skip to main content

reovim_tui_mod_explorer/
lib.rs

1#![cfg_attr(coverage_nightly, allow(unused_features))]
2#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
3//! File explorer sidebar native module for reovim TUI.
4//!
5//! Renders a sidebar tree view on the left side of the terminal.
6//! Receives state from the server via `on_notification()` and
7//! renders through `RenderSurface`.
8//!
9//! # Layout
10//!
11//! ```text
12//! +--------+------------------------------------------+
13//! | project|  Editor Content                          |
14//! |--------|                                          |
15//! | v src/ |  fn main() {                             |
16//! |   main |      println!("hello");                  |
17//! |   lib  |  }                                       |
18//! | > test |                                          |
19//! | readme |                                          |
20//! +--------+------------------------------------------+
21//! ```
22
23mod layout;
24mod render;
25
26use reovim_client_driver::{
27    ChromePosition, ClientModule, ClientModuleError, ModuleContext, PlatformCapabilities,
28    ProbeResult, Rect, RenderSurface, Version,
29};
30
31use crate::layout::SidebarBounds;
32
33/// Deserialized explorer state from server notification.
34#[derive(Debug, Default)]
35pub(crate) struct ExplorerData {
36    pub active: bool,
37    pub root_name: String,
38    pub cursor_index: usize,
39    pub scroll_offset: usize,
40    pub width: u16,
41    pub input_mode: String,
42    pub input_buffer: String,
43    pub input_label: String,
44    pub message: Option<String>,
45    pub show_hidden: bool,
46    pub nodes: Vec<NodeData>,
47}
48
49/// Deserialized tree node data.
50#[derive(Debug)]
51#[allow(clippy::struct_excessive_bools)]
52pub(crate) struct NodeData {
53    pub name: String,
54    pub depth: usize,
55    pub is_dir: bool,
56    pub is_expanded: bool,
57    pub is_hidden: bool,
58    pub is_last: bool,
59    pub vertical_lines: Vec<bool>,
60    pub is_symlink: bool,
61    #[allow(dead_code)] // Reserved for file details popup
62    pub size: u64,
63}
64
65/// Explorer sidebar native client module.
66///
67/// Renders the file tree sidebar when active, using `ChromePosition::Left`.
68pub struct ExplorerModule {
69    data: ExplorerData,
70}
71
72impl ExplorerModule {
73    /// Create a new inactive explorer module.
74    #[must_use]
75    pub fn new() -> Self {
76        Self {
77            data: ExplorerData::default(),
78        }
79    }
80}
81
82impl Default for ExplorerModule {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88#[cfg_attr(coverage_nightly, coverage(off))]
89impl ClientModule for ExplorerModule {
90    fn id(&self) -> &'static str {
91        "explorer"
92    }
93
94    fn kind(&self) -> &'static str {
95        "explorer"
96    }
97
98    fn name(&self) -> &'static str {
99        "Explorer"
100    }
101
102    fn version(&self) -> Version {
103        Version::new(0, 1, 0)
104    }
105
106    fn init(&mut self, _ctx: &ModuleContext) -> ProbeResult {
107        ProbeResult::Success
108    }
109
110    fn exit(&mut self) -> Result<(), ClientModuleError> {
111        Ok(())
112    }
113
114    fn has_chrome(&self) -> bool {
115        true
116    }
117
118    fn chrome_position(&self) -> ChromePosition {
119        ChromePosition::Left
120    }
121
122    fn chrome_requested_size(&self, _caps: &dyn PlatformCapabilities) -> u16 {
123        if self.data.active { self.data.width } else { 0 }
124    }
125
126    fn chrome_priority(&self) -> u16 {
127        60
128    }
129
130    #[allow(clippy::cast_possible_truncation)]
131    fn on_notification(&mut self, data: &str) {
132        let Ok(json) = serde_json::from_str::<serde_json::Value>(data) else {
133            return;
134        };
135
136        let active = json["active"].as_bool().unwrap_or(false);
137        if !active {
138            self.data.active = false;
139            return;
140        }
141
142        self.data.active = true;
143        json["rootName"]
144            .as_str()
145            .unwrap_or("")
146            .clone_into(&mut self.data.root_name);
147        self.data.cursor_index = json["cursorIndex"].as_u64().unwrap_or(0) as usize;
148        self.data.scroll_offset = json["scrollOffset"].as_u64().unwrap_or(0) as usize;
149        self.data.width = json["width"].as_u64().unwrap_or(30) as u16;
150        json["inputMode"]
151            .as_str()
152            .unwrap_or("none")
153            .clone_into(&mut self.data.input_mode);
154        json["inputBuffer"]
155            .as_str()
156            .unwrap_or("")
157            .clone_into(&mut self.data.input_buffer);
158        self.data.show_hidden = json["showHidden"].as_bool().unwrap_or(false);
159        self.data.message = json["message"].as_str().map(str::to_owned);
160
161        // Derive input label from input mode
162        self.data.input_label = match self.data.input_mode.as_str() {
163            "createFile" => "New file: ".to_owned(),
164            "createDir" => "New dir: ".to_owned(),
165            "rename" => "Rename: ".to_owned(),
166            "confirmDelete" => "Delete? (y/n): ".to_owned(),
167            _ => String::new(),
168        };
169
170        // Delta snapshot support: if the server omits "nodes", keep the
171        // existing node data (only metadata like cursorIndex changed).
172        if let Some(arr) = json["nodes"].as_array() {
173            self.data.nodes = arr
174                .iter()
175                .map(|item| NodeData {
176                    name: item["name"].as_str().unwrap_or("").to_owned(),
177                    depth: item["depth"].as_u64().unwrap_or(0) as usize,
178                    is_dir: item["isDir"].as_bool().unwrap_or(false),
179                    is_expanded: item["isExpanded"].as_bool().unwrap_or(false),
180                    is_hidden: item["isHidden"].as_bool().unwrap_or(false),
181                    is_last: item["isLast"].as_bool().unwrap_or(false),
182                    vertical_lines: item["verticalLines"]
183                        .as_array()
184                        .map(|a| a.iter().filter_map(serde_json::Value::as_bool).collect())
185                        .unwrap_or_default(),
186                    is_symlink: item["isSymlink"].as_bool().unwrap_or(false),
187                    size: item["size"].as_u64().unwrap_or(0),
188                })
189                .collect();
190        }
191    }
192
193    #[allow(clippy::cast_possible_truncation)]
194    fn cursor_position(&self, _w: u16, h: u16) -> Option<(u16, u16)> {
195        if !self.data.active || self.data.input_mode == "none" {
196            return None;
197        }
198
199        // When in input mode, calculate bounds assuming the sidebar starts at x=0, y=0
200        // with the full terminal height. The chrome system positions us at (0, 0).
201        let bounds = SidebarBounds::calculate(0, 0, self.data.width, h, true);
202        let input_y = bounds.input_y?;
203
204        // Cursor at end of input label + input buffer
205        let cursor_x = 1 + self.data.input_label.len() as u16 + self.data.input_buffer.len() as u16;
206        Some((cursor_x.min(self.data.width.saturating_sub(2)), input_y))
207    }
208
209    fn chrome_render(
210        &self,
211        surface: &mut dyn RenderSurface,
212        bounds: Rect,
213        _caps: &dyn PlatformCapabilities,
214    ) {
215        if !self.data.active {
216            return;
217        }
218
219        let has_input = self.data.input_mode != "none";
220        let sidebar_bounds =
221            SidebarBounds::calculate(bounds.x, bounds.y, bounds.width, bounds.height, has_input);
222        render::render_explorer(surface, &self.data, &sidebar_bounds);
223    }
224}
225
226#[cfg(test)]
227#[path = "lib_tests.rs"]
228mod tests;