Skip to main content

ane/data/
state.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3
4use anyhow::Result;
5
6use super::buffer::Buffer;
7use super::file_tree::{FileEntry, FileTree};
8use super::lsp::types::LspSharedState;
9
10#[derive(Debug, Clone)]
11pub struct ListDialogState {
12    pub items: Vec<(String, usize, usize)>, // (val, line, col)
13    pub selected: usize,
14}
15
16#[derive(Debug, Clone)]
17pub struct TreeRenameState {
18    pub index: usize,
19    pub input: String,
20    pub cursor: usize,
21}
22
23#[derive(Debug, Clone)]
24pub struct TreeDeleteState {
25    pub index: usize,
26    pub children_preview: Vec<String>,
27}
28
29#[derive(Debug, Clone)]
30pub struct TreeNewFileState {
31    pub parent_dir: PathBuf,
32    pub input: String,
33    pub cursor: usize,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Mode {
38    Edit,
39    Chord,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Selection {
44    pub anchor_line: usize,
45    pub anchor_col: usize,
46    pub head_line: usize,
47    pub head_col: usize,
48}
49
50impl Selection {
51    pub fn ordered(&self) -> (usize, usize, usize, usize) {
52        if (self.anchor_line, self.anchor_col) <= (self.head_line, self.head_col) {
53            (
54                self.anchor_line,
55                self.anchor_col,
56                self.head_line,
57                self.head_col,
58            )
59        } else {
60            (
61                self.head_line,
62                self.head_col,
63                self.anchor_line,
64                self.anchor_col,
65            )
66        }
67    }
68}
69
70#[derive(Debug)]
71pub struct EditorState {
72    pub buffers: Vec<Buffer>,
73    pub active_buffer: usize,
74    pub file_tree: Option<FileTree>,
75    pub cursor_line: usize,
76    pub cursor_col: usize,
77    pub scroll_offset: usize,
78    pub mode: Mode,
79    pub should_quit: bool,
80    pub status_msg: String,
81    pub tree_selected: usize,
82    pub focus_tree: bool,
83    pub chord_input: String,
84    pub show_exit_modal: bool,
85    pub opened_path: PathBuf,
86    pub chord_cursor_col: usize,
87    pub chord_error: bool,
88    pub chord_running: bool,
89    pub chord_history: Vec<String>,
90    pub chord_history_index: Option<usize>,
91    pub pre_tree_mode: Mode,
92    pub pending_open_path: Option<PathBuf>,
93    pub tree_view: Vec<FileEntry>,
94    pub lsp_state: Arc<Mutex<LspSharedState>>,
95    pub selection: Option<Selection>,
96    pub list_dialog: Option<ListDialogState>,
97    pub cached_token_count: usize,
98    pub disk_changed_path: Option<PathBuf>,
99    pub pending_rewatch_path: Option<PathBuf>,
100    pub tree_rename_state: Option<TreeRenameState>,
101    pub tree_delete_confirm: Option<TreeDeleteState>,
102    pub tree_new_file_state: Option<TreeNewFileState>,
103}
104
105impl EditorState {
106    pub fn for_file(path: &Path) -> Result<Self> {
107        let buf = if path.exists() {
108            Buffer::from_file(path)?
109        } else {
110            Buffer::empty(path)
111        };
112        Ok(Self {
113            buffers: vec![buf],
114            active_buffer: 0,
115            file_tree: None,
116            cursor_line: 0,
117            cursor_col: 0,
118            scroll_offset: 0,
119            mode: Mode::Chord,
120            should_quit: false,
121            status_msg: String::new(),
122            tree_selected: 0,
123            focus_tree: false,
124            chord_input: String::new(),
125            show_exit_modal: false,
126            opened_path: path.to_path_buf(),
127            chord_cursor_col: 0,
128            chord_error: false,
129            chord_running: false,
130            chord_history: Vec::new(),
131            chord_history_index: None,
132            pre_tree_mode: Mode::Chord,
133            pending_open_path: None,
134            tree_view: Vec::new(),
135            lsp_state: Arc::new(Mutex::new(LspSharedState::default())),
136            selection: None,
137            list_dialog: None,
138            cached_token_count: 0,
139            disk_changed_path: None,
140            pending_rewatch_path: None,
141            tree_rename_state: None,
142            tree_delete_confirm: None,
143            tree_new_file_state: None,
144        })
145    }
146
147    pub fn for_directory(path: &Path) -> Result<Self> {
148        let tree = FileTree::from_dir(path)?;
149        let tree_view: Vec<FileEntry> = tree
150            .entries
151            .iter()
152            .filter(|e| e.depth == 0)
153            .cloned()
154            .collect();
155        Ok(Self {
156            buffers: Vec::new(),
157            active_buffer: 0,
158            file_tree: Some(tree),
159            cursor_line: 0,
160            cursor_col: 0,
161            scroll_offset: 0,
162            mode: Mode::Chord,
163            should_quit: false,
164            status_msg: String::new(),
165            tree_selected: 0,
166            focus_tree: true,
167            chord_input: String::new(),
168            show_exit_modal: false,
169            opened_path: path.to_path_buf(),
170            chord_cursor_col: 0,
171            chord_error: false,
172            chord_running: false,
173            chord_history: Vec::new(),
174            chord_history_index: None,
175            pre_tree_mode: Mode::Chord,
176            pending_open_path: None,
177            tree_view,
178            lsp_state: Arc::new(Mutex::new(LspSharedState::default())),
179            selection: None,
180            list_dialog: None,
181            cached_token_count: 0,
182            disk_changed_path: None,
183            pending_rewatch_path: None,
184            tree_rename_state: None,
185            tree_delete_confirm: None,
186            tree_new_file_state: None,
187        })
188    }
189
190    pub fn current_buffer(&self) -> Option<&Buffer> {
191        self.buffers.get(self.active_buffer)
192    }
193
194    pub fn current_buffer_mut(&mut self) -> Option<&mut Buffer> {
195        self.buffers.get_mut(self.active_buffer)
196    }
197
198    pub fn open_file(&mut self, path: &Path) -> Result<()> {
199        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
200        if let Some(idx) = self
201            .buffers
202            .iter()
203            .position(|b| b.path == canonical || b.path == path)
204        {
205            if path.exists() {
206                if self.buffers[idx].dirty {
207                    let new_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
208                    if new_mtime != self.buffers[idx].last_disk_mtime {
209                        self.buffers[idx].disk_changed = true;
210                    }
211                } else if let Ok(fresh) = Buffer::from_file(path) {
212                    self.buffers[idx] = fresh;
213                }
214            }
215            self.active_buffer = idx;
216        } else {
217            let buf = Buffer::from_file(path)?;
218            self.buffers.push(buf);
219            self.active_buffer = self.buffers.len() - 1;
220        }
221        self.cursor_line = 0;
222        self.cursor_col = 0;
223        self.scroll_offset = 0;
224        self.focus_tree = false;
225        Ok(())
226    }
227
228    pub fn snapshot_contents(&self) -> Vec<(PathBuf, String)> {
229        self.buffers
230            .iter()
231            .map(|b| (b.path.clone(), b.content()))
232            .collect()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::fs;
240    use tempfile::TempDir;
241
242    #[test]
243    fn for_file_initializes_no_tree_and_empty_tree_view() {
244        let tmp = TempDir::new().unwrap();
245        let path = tmp.path().join("test.rs");
246        fs::write(&path, "fn main() {}").unwrap();
247
248        let state = EditorState::for_file(&path).unwrap();
249
250        assert!(state.file_tree.is_none());
251        assert!(state.tree_view.is_empty());
252    }
253
254    #[test]
255    fn for_file_chord_fields_initialized_to_defaults() {
256        let tmp = TempDir::new().unwrap();
257        let path = tmp.path().join("test.rs");
258        fs::write(&path, "fn main() {}").unwrap();
259
260        let state = EditorState::for_file(&path).unwrap();
261
262        assert_eq!(state.chord_cursor_col, 0);
263        assert!(!state.chord_error);
264        assert!(!state.chord_running);
265    }
266
267    #[test]
268    fn for_directory_tree_view_contains_only_depth_zero_entries() {
269        let tmp = TempDir::new().unwrap();
270        fs::create_dir(tmp.path().join("subdir")).unwrap();
271        fs::write(tmp.path().join("file.rs"), "").unwrap();
272        fs::write(tmp.path().join("subdir/nested.rs"), "").unwrap();
273
274        let state = EditorState::for_directory(tmp.path()).unwrap();
275
276        assert!(!state.tree_view.is_empty());
277        for entry in &state.tree_view {
278            assert_eq!(entry.depth, 0, "unexpected depth for {:?}", entry.path);
279        }
280        let tree = state.file_tree.as_ref().unwrap();
281        assert!(
282            tree.entries.iter().any(|e| e.depth > 0),
283            "full tree should have nested entries"
284        );
285    }
286
287    #[test]
288    fn for_directory_chord_fields_initialized_to_defaults() {
289        let tmp = TempDir::new().unwrap();
290        fs::write(tmp.path().join("x.rs"), "").unwrap();
291
292        let state = EditorState::for_directory(tmp.path()).unwrap();
293
294        assert_eq!(state.chord_cursor_col, 0);
295        assert!(!state.chord_error);
296        assert!(!state.chord_running);
297    }
298
299    #[test]
300    fn selection_ordered_forward_drag() {
301        let sel = Selection {
302            anchor_line: 0,
303            anchor_col: 5,
304            head_line: 2,
305            head_col: 10,
306        };
307        assert_eq!(sel.ordered(), (0, 5, 2, 10));
308    }
309
310    #[test]
311    fn selection_ordered_backward_drag() {
312        let sel = Selection {
313            anchor_line: 2,
314            anchor_col: 10,
315            head_line: 0,
316            head_col: 5,
317        };
318        assert_eq!(sel.ordered(), (0, 5, 2, 10));
319    }
320
321    #[test]
322    fn selection_ordered_same_line_backward() {
323        let sel = Selection {
324            anchor_line: 3,
325            anchor_col: 20,
326            head_line: 3,
327            head_col: 5,
328        };
329        assert_eq!(sel.ordered(), (3, 5, 3, 20));
330    }
331
332    #[test]
333    fn open_file_reloads_clean_buffer_from_disk() {
334        let tmp = TempDir::new().unwrap();
335        let path = tmp.path().join("test.rs");
336        fs::write(&path, "original").unwrap();
337
338        let mut state = EditorState::for_file(&path).unwrap();
339        assert_eq!(state.current_buffer().unwrap().content(), "original");
340
341        fs::write(&path, "updated").unwrap();
342        state.open_file(&path).unwrap();
343        assert_eq!(state.current_buffer().unwrap().content(), "updated");
344        assert!(!state.current_buffer().unwrap().dirty);
345    }
346
347    #[test]
348    fn open_file_sets_disk_changed_on_dirty_buffer_with_new_mtime() {
349        let tmp = TempDir::new().unwrap();
350        let path = tmp.path().join("test.rs");
351        fs::write(&path, "original").unwrap();
352
353        let mut state = EditorState::for_file(&path).unwrap();
354        state.buffers[0].dirty = true;
355
356        // Simulate external modification by writing new content (changes mtime)
357        std::thread::sleep(std::time::Duration::from_millis(50));
358        fs::write(&path, "external change").unwrap();
359
360        state.open_file(&path).unwrap();
361        assert!(
362            state.current_buffer().unwrap().disk_changed,
363            "disk_changed must be set when dirty buffer has stale mtime"
364        );
365        assert!(state.current_buffer().unwrap().dirty);
366        assert_eq!(
367            state.current_buffer().unwrap().content(),
368            "original",
369            "dirty buffer content should be preserved until user chooses"
370        );
371    }
372
373    #[test]
374    fn open_file_keeps_dirty_buffer_when_mtime_unchanged() {
375        let tmp = TempDir::new().unwrap();
376        let path = tmp.path().join("test.rs");
377        fs::write(&path, "original").unwrap();
378
379        let mut state = EditorState::for_file(&path).unwrap();
380        state.buffers[0].dirty = true;
381        state.buffers[0].lines[0] = "edited".to_string();
382
383        state.open_file(&path).unwrap();
384        assert!(
385            !state.current_buffer().unwrap().disk_changed,
386            "disk_changed must not be set when mtime has not changed"
387        );
388        assert_eq!(state.current_buffer().unwrap().content(), "edited");
389    }
390}