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)>, 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 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}