Skip to main content

fresh/app/
file_operations.rs

1//! File operations for the Editor.
2//!
3//! This module contains file I/O and watching operations:
4//! - Saving buffers
5//! - Reverting to saved version
6//! - Auto-revert and file change polling
7//! - LSP file notifications (open, change)
8//! - File modification time tracking
9//! - Save conflict detection
10
11use crate::model::buffer::SudoSaveRequired;
12use crate::model::filesystem::FileSystem;
13use crate::view::file_tree::FileTreeView;
14use crate::view::prompt::PromptType;
15use std::path::{Path, PathBuf};
16
17use lsp_types::TextDocumentContentChangeEvent;
18use rust_i18n::t;
19
20use crate::model::event::{BufferId, EventLog};
21use crate::services::lsp::manager::LspSpawnResult;
22use crate::state::EditorState;
23
24use super::{BufferMetadata, Editor};
25
26impl Editor {
27    /// Save the active buffer
28    pub fn save(&mut self) -> anyhow::Result<()> {
29        // Fail fast if remote connection is down
30        if !self.authority.filesystem.is_remote_connected() {
31            anyhow::bail!(
32                "Cannot save: remote connection lost ({})",
33                self.authority
34                    .filesystem
35                    .remote_connection_info()
36                    .unwrap_or("unknown host")
37            );
38        }
39
40        let path = self
41            .active_state()
42            .buffer
43            .file_path()
44            .map(|p| p.to_path_buf());
45
46        match self.active_state_mut().buffer.save() {
47            Ok(()) => self.finalize_save(path),
48            Err(e) => {
49                if let Some(sudo_info) = e.downcast_ref::<SudoSaveRequired>() {
50                    let info = sudo_info.clone();
51                    self.start_prompt(
52                        t!("prompt.sudo_save_confirm").to_string(),
53                        PromptType::ConfirmSudoSave { info },
54                    );
55                    Ok(())
56                } else if let Some(path) = path {
57                    // Check if failure is due to non-existent parent directory
58                    let is_not_found = e
59                        .downcast_ref::<std::io::Error>()
60                        .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound);
61                    if is_not_found {
62                        if let Some(parent) = path.parent() {
63                            if !self.authority.filesystem.exists(parent) {
64                                let dir_name = parent
65                                    .strip_prefix(&self.working_dir)
66                                    .unwrap_or(parent)
67                                    .display()
68                                    .to_string();
69                                self.start_prompt(
70                                    t!("buffer.create_directory_confirm", name = &dir_name)
71                                        .to_string(),
72                                    PromptType::ConfirmCreateDirectory { path },
73                                );
74                                return Ok(());
75                            }
76                        }
77                    }
78                    Err(e)
79                } else {
80                    Err(e)
81                }
82            }
83        }
84    }
85
86    /// Internal helper to finalize save state (mark as saved, notify LSP, etc.)
87    pub(crate) fn finalize_save(&mut self, path: Option<PathBuf>) -> anyhow::Result<()> {
88        let buffer_id = self.active_buffer();
89        self.finalize_save_buffer(buffer_id, path, false)
90    }
91
92    /// Internal helper to finalize save state for a specific buffer
93    pub(crate) fn finalize_save_buffer(
94        &mut self,
95        buffer_id: BufferId,
96        path: Option<PathBuf>,
97        silent: bool,
98    ) -> anyhow::Result<()> {
99        // Auto-detect language if it's currently "text" and we have a path
100        if let Some(ref p) = path {
101            if let Some(state) = self.buffers.get_mut(&buffer_id) {
102                if state.language == "text" {
103                    let first_line = state.buffer.first_line_lossy();
104                    let detected =
105                        crate::primitives::detected_language::DetectedLanguage::from_path(
106                            p,
107                            first_line.as_deref(),
108                            &self.grammar_registry,
109                            &self.config.languages,
110                        );
111                    state.apply_language(detected);
112                }
113            }
114        }
115
116        if !silent {
117            self.status_message = Some(t!("status.file_saved").to_string());
118        }
119
120        // Mark the event log position as saved (for undo modified tracking)
121        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
122            event_log.mark_saved();
123        }
124
125        // Update file modification time after save
126        if let Some(ref p) = path {
127            if let Ok(metadata) = self.authority.filesystem.metadata(p) {
128                if let Some(mtime) = metadata.modified {
129                    self.file_mod_times.insert(p.clone(), mtime);
130                }
131            }
132        }
133
134        // Reload .gitignore in the file explorer when the user saves one.
135        // Otherwise the tree keeps filtering by the old rules until restart.
136        if let Some(ref p) = path {
137            if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
138                if let Some(parent) = p.parent() {
139                    let parent = parent.to_path_buf();
140                    let fs = self.authority.filesystem.clone();
141                    if let Some(explorer) = self.file_explorer.as_mut() {
142                        load_gitignore_via_fs(fs.as_ref(), explorer, &parent);
143                    }
144                }
145            }
146        }
147
148        // Notify LSP of save
149        self.notify_lsp_save_buffer(buffer_id);
150
151        // Delete recovery file (buffer is now saved)
152        if let Err(e) = self.delete_buffer_recovery(buffer_id) {
153            tracing::warn!("Failed to delete recovery file: {}", e);
154        }
155
156        // Emit control event
157        if let Some(ref p) = path {
158            self.emit_event(
159                crate::model::control_event::events::FILE_SAVED.name,
160                serde_json::json!({
161                    "path": p.display().to_string()
162                }),
163            );
164        }
165
166        // Fire AfterFileSave hook for plugins
167        if let Some(ref p) = path {
168            self.plugin_manager.run_hook(
169                "after_file_save",
170                crate::services::plugins::hooks::HookArgs::AfterFileSave {
171                    buffer_id,
172                    path: p.clone(),
173                },
174            );
175        }
176
177        // Run on-save actions (formatters, linters, etc.)
178        // Note: run_on_save_actions also assumes active_buffer internally.
179        // We might need to refactor it too if we want auto-save to trigger formatters.
180        // For now, let's just do it for active buffer or skip for silent auto-saves.
181
182        if !silent {
183            match self.run_on_save_actions() {
184                Ok(true) => {
185                    // Actions ran successfully - if status_message was set by run_on_save_actions
186                    // (e.g., for missing optional formatters), keep it. Otherwise update status.
187                    if self.status_message.as_deref() == Some(&t!("status.file_saved")) {
188                        self.status_message =
189                            Some(t!("status.file_saved_with_actions").to_string());
190                    }
191                    // else: keep the message set by run_on_save_actions (e.g., missing formatter)
192                }
193                Ok(false) => {
194                    // No actions configured, keep original status
195                }
196                Err(e) => {
197                    // Action failed, show error but don't fail the save
198                    self.status_message = Some(e);
199                }
200            }
201        }
202
203        Ok(())
204    }
205
206    /// Auto-save all modified buffers to their original files on disk
207    /// Returns the number of buffers saved
208    pub fn auto_save_persistent_buffers(&mut self) -> anyhow::Result<usize> {
209        if !self.config.editor.auto_save_enabled {
210            return Ok(0);
211        }
212
213        // Check if enough time has passed since last auto-save
214        let interval =
215            std::time::Duration::from_secs(self.config.editor.auto_save_interval_secs as u64);
216        if self
217            .time_source
218            .elapsed_since(self.last_persistent_auto_save)
219            < interval
220        {
221            return Ok(0);
222        }
223
224        self.last_persistent_auto_save = self.time_source.now();
225
226        // Collect info for modified buffers that have a file path
227        let mut to_save = Vec::new();
228        for (id, state) in &self.buffers {
229            if state.buffer.is_modified() {
230                if let Some(path) = state.buffer.file_path() {
231                    to_save.push((*id, path.to_path_buf()));
232                }
233            }
234        }
235
236        let mut count = 0;
237        for (id, path) in to_save {
238            if let Some(state) = self.buffers.get_mut(&id) {
239                match state.buffer.save() {
240                    Ok(()) => {
241                        self.finalize_save_buffer(id, Some(path), true)?;
242                        count += 1;
243                    }
244                    Err(e) => {
245                        // Skip if sudo is required (auto-save can't handle prompts)
246                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
247                            tracing::debug!(
248                                "Auto-save skipped for {:?} (sudo required)",
249                                path.display()
250                            );
251                        } else {
252                            tracing::warn!("Auto-save failed for {:?}: {}", path.display(), e);
253                        }
254                    }
255                }
256            }
257        }
258
259        Ok(count)
260    }
261
262    /// Save all modified file-backed buffers to disk (called on exit when auto_save is enabled).
263    /// Unlike `auto_save_persistent_buffers`, this skips the interval check and only saves
264    /// named file-backed buffers (not unnamed buffers).
265    pub fn save_all_on_exit(&mut self) -> anyhow::Result<usize> {
266        let mut to_save = Vec::new();
267        for (id, state) in &self.buffers {
268            if state.buffer.is_modified() {
269                if let Some(path) = state.buffer.file_path() {
270                    if !path.as_os_str().is_empty() {
271                        to_save.push((*id, path.to_path_buf()));
272                    }
273                }
274            }
275        }
276
277        let mut count = 0;
278        for (id, path) in to_save {
279            if let Some(state) = self.buffers.get_mut(&id) {
280                match state.buffer.save() {
281                    Ok(()) => {
282                        self.finalize_save_buffer(id, Some(path), true)?;
283                        count += 1;
284                    }
285                    Err(e) => {
286                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
287                            tracing::debug!(
288                                "Auto-save on exit skipped for {} (sudo required)",
289                                path.display()
290                            );
291                        } else {
292                            tracing::warn!(
293                                "Auto-save on exit failed for {}: {}",
294                                path.display(),
295                                e
296                            );
297                        }
298                    }
299                }
300            }
301        }
302
303        Ok(count)
304    }
305
306    /// Revert the active buffer to the last saved version on disk
307    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
308    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
309        let path = match self.active_state().buffer.file_path() {
310            Some(p) => p.to_path_buf(),
311            None => {
312                self.status_message = Some(t!("status.no_file_to_revert").to_string());
313                return Ok(false);
314            }
315        };
316
317        if !path.exists() {
318            self.status_message =
319                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
320            return Ok(false);
321        }
322
323        // Save scroll position (from SplitViewState) and cursor positions before reloading
324        let active_split = self.split_manager.active_split();
325        let (old_top_byte, old_left_column) = self
326            .split_view_states
327            .get(&active_split)
328            .map(|vs| (vs.viewport.top_byte, vs.viewport.left_column))
329            .unwrap_or((0, 0));
330        let old_cursors = self.active_cursors().clone();
331
332        // Preserve user settings before reloading
333        let old_buffer_settings = self.active_state().buffer_settings.clone();
334        let old_editing_disabled = self.active_state().editing_disabled;
335
336        // Load the file content fresh from disk
337        let mut new_state = EditorState::from_file_with_languages(
338            &path,
339            self.terminal_width,
340            self.terminal_height,
341            self.config.editor.large_file_threshold_bytes as usize,
342            &self.grammar_registry,
343            &self.config.languages,
344            std::sync::Arc::clone(&self.authority.filesystem),
345        )?;
346
347        // Restore cursor positions (clamped to valid range for new file size)
348        let new_file_size = new_state.buffer.len();
349        let mut restored_cursors = old_cursors;
350        restored_cursors.map(|cursor| {
351            cursor.position = cursor.position.min(new_file_size);
352            // Clear selection since the content may have changed
353            cursor.clear_selection();
354        });
355        // Restore user settings (tab size, indentation, etc.)
356        new_state.buffer_settings = old_buffer_settings;
357        new_state.editing_disabled = old_editing_disabled;
358        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
359
360        // Replace the current buffer with the new state
361        let buffer_id = self.active_buffer();
362        if let Some(state) = self.buffers.get_mut(&buffer_id) {
363            *state = new_state;
364            // Note: line_wrap_enabled is now in SplitViewState.viewport
365        }
366
367        // Restore cursor positions in SplitViewState (clamped to valid range for new file size)
368        let active_split = self.split_manager.active_split();
369        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
370            view_state.cursors = restored_cursors;
371        }
372
373        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
374        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
375            view_state.viewport.top_byte = old_top_byte.min(new_file_size);
376            view_state.viewport.left_column = old_left_column;
377        }
378
379        // Clear the undo/redo history for this buffer
380        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
381            *event_log = EventLog::new();
382        }
383
384        // Clear seen_byte_ranges so plugins get notified of all visible lines
385        self.seen_byte_ranges.remove(&buffer_id);
386
387        // Update the file modification time
388        if let Ok(metadata) = self.authority.filesystem.metadata(&path) {
389            if let Some(mtime) = metadata.modified {
390                self.file_mod_times.insert(path.clone(), mtime);
391            }
392        }
393
394        // Notify LSP that the file was changed
395        self.notify_lsp_file_changed(&path);
396
397        self.status_message = Some(t!("status.reverted").to_string());
398        Ok(true)
399    }
400
401    /// Toggle auto-revert mode
402    pub fn toggle_auto_revert(&mut self) {
403        self.auto_revert_enabled = !self.auto_revert_enabled;
404
405        if self.auto_revert_enabled {
406            self.status_message = Some(t!("status.auto_revert_enabled").to_string());
407        } else {
408            self.status_message = Some(t!("status.auto_revert_disabled").to_string());
409        }
410    }
411
412    /// Poll for file changes (called from main loop)
413    ///
414    /// Checks modification times of open files to detect external changes.
415    /// Returns true if any file was changed (requires re-render).
416    ///
417    /// To avoid blocking the event loop, metadata checks run on a background
418    /// thread. This method launches a poll if the interval has elapsed and no
419    /// poll is already in flight, then checks for results from a prior poll.
420    pub fn poll_file_changes(&mut self) -> bool {
421        // Skip if auto-revert is disabled
422        if !self.auto_revert_enabled {
423            return false;
424        }
425
426        // Check for results from a previous background poll
427        let mut any_changed = false;
428        if let Some(ref rx) = self.pending_file_poll_rx {
429            match rx.try_recv() {
430                Ok(results) => {
431                    self.pending_file_poll_rx = None;
432                    any_changed = self.process_file_poll_results(results);
433                }
434                Err(std::sync::mpsc::TryRecvError::Empty) => {
435                    // Still in progress — don't block, don't start another
436                    return false;
437                }
438                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
439                    // Background task panicked or was dropped
440                    self.pending_file_poll_rx = None;
441                }
442            }
443        }
444
445        // Check poll interval
446        let poll_interval =
447            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
448        let elapsed = self.time_source.elapsed_since(self.last_auto_revert_poll);
449        tracing::trace!(
450            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
451            elapsed,
452            poll_interval
453        );
454        if elapsed < poll_interval {
455            return any_changed;
456        }
457        self.last_auto_revert_poll = self.time_source.now();
458
459        // Collect paths of open files that need checking
460        let files_to_check: Vec<PathBuf> = self
461            .buffers
462            .values()
463            .filter_map(|state| state.buffer.file_path().map(PathBuf::from))
464            .collect();
465
466        if files_to_check.is_empty() {
467            return any_changed;
468        }
469
470        // Spawn background metadata checks
471        let (tx, rx) = std::sync::mpsc::channel();
472        let fs = self.authority.filesystem.clone();
473        std::thread::Builder::new()
474            .name("poll-file-changes".to_string())
475            .spawn(move || {
476                let results: Vec<(PathBuf, Option<std::time::SystemTime>)> = files_to_check
477                    .into_iter()
478                    .map(|path| {
479                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
480                        (path, mtime)
481                    })
482                    .collect();
483                // Receiver may have been dropped if auto-revert was disabled
484                // or the editor is shutting down — that's fine.
485                if tx.send(results).is_err() {}
486            })
487            .ok();
488        self.pending_file_poll_rx = Some(rx);
489
490        any_changed
491    }
492
493    /// Process results from a background file poll
494    fn process_file_poll_results(
495        &mut self,
496        results: Vec<(PathBuf, Option<std::time::SystemTime>)>,
497    ) -> bool {
498        let mut any_changed = false;
499        for (path, mtime_opt) in results {
500            let Some(current_mtime) = mtime_opt else {
501                continue;
502            };
503
504            if let Some(&stored_mtime) = self.file_mod_times.get(&path) {
505                if current_mtime != stored_mtime {
506                    let path_str = path.display().to_string();
507                    if self.handle_async_file_changed(path_str) {
508                        any_changed = true;
509                    }
510                }
511            } else {
512                // First time seeing this file, record its mtime
513                self.file_mod_times.insert(path, current_mtime);
514            }
515        }
516        any_changed
517    }
518
519    /// Poll for file tree changes (called from main loop)
520    ///
521    /// Checks modification times of expanded directories to detect new/deleted files.
522    /// Returns true if any directory was refreshed (requires re-render).
523    ///
524    /// Like poll_file_changes, metadata checks run on a background thread to
525    /// avoid blocking the event loop.
526    pub fn poll_file_tree_changes(&mut self) -> bool {
527        use crate::view::file_tree::NodeId;
528
529        // Check for results from a previous background poll
530        let mut any_refreshed = false;
531        let mut dir_poll_pending = false;
532        if let Some(ref rx) = self.pending_dir_poll_rx {
533            match rx.try_recv() {
534                Ok((dir_results, git_index_mtime)) => {
535                    self.pending_dir_poll_rx = None;
536                    any_refreshed = self.process_dir_poll_results(dir_results, git_index_mtime);
537                }
538                Err(std::sync::mpsc::TryRecvError::Empty) => {
539                    dir_poll_pending = true;
540                }
541                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
542                    self.pending_dir_poll_rx = None;
543                }
544            }
545        }
546
547        // Check poll interval
548        let poll_interval =
549            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
550        if self.time_source.elapsed_since(self.last_file_tree_poll) < poll_interval {
551            return any_refreshed;
552        }
553        self.last_file_tree_poll = self.time_source.now();
554
555        // Re-stat every loaded .gitignore and reload/drop as needed, so
556        // external edits (git pull, sed, another editor) and deletions take
557        // effect without a restart. In-editor saves already reload eagerly
558        // via finalize_save_buffer. Sync I/O here — a handful of small files
559        // and all access goes through the filesystem authority.
560        if self.sync_gitignores_from_disk() {
561            any_refreshed = true;
562        }
563
564        // If a previous dir-poll is still in flight, don't stack another.
565        if dir_poll_pending {
566            return any_refreshed;
567        }
568
569        // Resolve the git index path once (first poll only). This uses the
570        // ProcessSpawner which may block briefly on the first call, but only
571        // happens once per session.
572        if !self.git_index_resolved {
573            self.git_index_resolved = true;
574            if let Some(path) = self.resolve_git_index() {
575                if let Ok(meta) = self.authority.filesystem.metadata(&path) {
576                    if let Some(mtime) = meta.modified {
577                        self.dir_mod_times.insert(path, mtime);
578                    }
579                }
580            }
581        }
582
583        // Get file explorer reference
584        let Some(explorer) = &self.file_explorer else {
585            return any_refreshed;
586        };
587
588        // Collect expanded directories (node_id, path)
589        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
590            .tree()
591            .all_nodes()
592            .filter(|node| node.is_dir() && node.is_expanded())
593            .map(|node| (node.id, node.entry.path.clone()))
594            .collect();
595
596        // Find the git index path to include in the background metadata check
597        let git_index_path: Option<PathBuf> = self
598            .dir_mod_times
599            .keys()
600            .find(|p| p.ends_with(".git/index") || p.ends_with(".git\\index"))
601            .cloned();
602
603        if expanded_dirs.is_empty() && git_index_path.is_none() {
604            return any_refreshed;
605        }
606
607        // Spawn background metadata checks (directories + git index)
608        let (tx, rx) = std::sync::mpsc::channel();
609        let fs = self.authority.filesystem.clone();
610        std::thread::Builder::new()
611            .name("poll-dir-changes".to_string())
612            .spawn(move || {
613                let results: Vec<(NodeId, PathBuf, Option<std::time::SystemTime>)> = expanded_dirs
614                    .into_iter()
615                    .map(|(node_id, path)| {
616                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
617                        (node_id, path, mtime)
618                    })
619                    .collect();
620
621                // Also check git index mtime in the same background thread
622                let git_index_mtime = git_index_path.and_then(|path| {
623                    let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
624                    Some((path, mtime?))
625                });
626
627                // Receiver may have been dropped during shutdown — that's fine.
628                if tx.send((results, git_index_mtime)).is_err() {}
629            })
630            .ok();
631        self.pending_dir_poll_rx = Some(rx);
632
633        any_refreshed
634    }
635
636    /// Process results from a background directory poll
637    fn process_dir_poll_results(
638        &mut self,
639        results: Vec<(
640            crate::view::file_tree::NodeId,
641            PathBuf,
642            Option<std::time::SystemTime>,
643        )>,
644        git_index_mtime: Option<(PathBuf, std::time::SystemTime)>,
645    ) -> bool {
646        let mut dirs_to_refresh: Vec<(crate::view::file_tree::NodeId, PathBuf)> = Vec::new();
647
648        for (node_id, path, mtime_opt) in results {
649            let Some(current_mtime) = mtime_opt else {
650                continue;
651            };
652
653            if let Some(&stored_mtime) = self.dir_mod_times.get(&path) {
654                if current_mtime != stored_mtime {
655                    self.dir_mod_times.insert(path.clone(), current_mtime);
656                    dirs_to_refresh.push((node_id, path.clone()));
657                    tracing::debug!("Directory changed: {:?}", path);
658                }
659            } else {
660                self.dir_mod_times.insert(path, current_mtime);
661            }
662        }
663
664        // Check if .git/index mtime changed (detected in background thread)
665        let git_index_changed = if let Some((path, current_mtime)) = git_index_mtime {
666            if let Some(&stored_mtime) = self.dir_mod_times.get(&path) {
667                if current_mtime != stored_mtime {
668                    self.dir_mod_times.insert(path, current_mtime);
669                    self.plugin_manager.run_hook(
670                        "focus_gained",
671                        crate::services::plugins::hooks::HookArgs::FocusGained {},
672                    );
673                    true
674                } else {
675                    false
676                }
677            } else {
678                false
679            }
680        } else {
681            false
682        };
683
684        if dirs_to_refresh.is_empty() && !git_index_changed {
685            return false;
686        }
687
688        // Refresh each changed directory and (re)load its .gitignore. A new
689        // .gitignore file inside an expanded dir bumps the dir's mtime so we
690        // land here; reload_expanded_node re-lists entries but doesn't parse
691        // rules — load_gitignore_via_fs handles the rules side.
692        let refreshed_dirs: Vec<PathBuf> = dirs_to_refresh.iter().map(|(_, p)| p.clone()).collect();
693        self.refresh_file_tree_dirs(&refreshed_dirs);
694        let fs = self.authority.filesystem.clone();
695        if let Some(explorer) = self.file_explorer.as_mut() {
696            for dir in refreshed_dirs {
697                load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
698            }
699        }
700
701        true
702    }
703
704    /// Re-read the given directories in the file explorer, preserving
705    /// descendant expansion state and the cursor's path.
706    ///
707    /// Why reload_expanded_node and not refresh_node: refresh_node
708    /// collapses the directory and re-expands it, which recycles every
709    /// descendant NodeId and drops their expansion state. That's fatal
710    /// for this code path, which runs unprompted from a background timer:
711    /// after a cut+paste into the workspace root, the source parent's
712    /// mtime changes, we land here seconds later, and refresh_node would
713    /// collapse a user-expanded subtree and invalidate the cursor
714    /// NodeId (after which Up/Down become no-ops because
715    /// select_next/select_prev can't find the current id in the visible
716    /// list).
717    ///
718    /// We also snapshot the cursor path before the reload and
719    /// re-resolve it afterwards. reload_expanded_node still recycles
720    /// ids under the refreshed root, so the old id is gone; the path
721    /// survives unless the underlying file was deleted, in which case
722    /// we fall back to the root so the cursor stays live and visible
723    /// (a stale id is effectively no cursor at all).
724    ///
725    /// Exposed as `pub` so tests can drive the refresh path directly
726    /// without relying on filesystem mtime detection, which is too
727    /// environment-sensitive (especially across CI filesystems).
728    pub fn refresh_file_tree_dirs(&mut self, paths: &[PathBuf]) {
729        let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) else {
730            return;
731        };
732        let cursor_path: Option<PathBuf> = explorer.get_selected_entry().map(|e| e.path.clone());
733        // Re-resolve node ids by path at each step: an earlier
734        // reload_expanded_node in this loop may have recycled ids under
735        // its subtree, so any ids captured before this call can be
736        // stale.
737        for path in paths {
738            let Some(id_now) = explorer.tree().get_node_by_path(path).map(|n| n.id) else {
739                continue;
740            };
741            let tree = explorer.tree_mut();
742            if let Err(e) = runtime.block_on(tree.reload_expanded_node(id_now)) {
743                tracing::warn!("Failed to refresh directory {:?}: {}", path, e);
744            }
745        }
746        if let Some(path) = cursor_path {
747            if explorer.tree().get_node_by_path(&path).is_some() {
748                explorer.navigate_to_path(&path);
749            } else {
750                let root_id = explorer.tree().root_id();
751                explorer.set_selected(Some(root_id));
752            }
753        }
754    }
755
756    /// Re-stat every loaded .gitignore via the filesystem authority and
757    /// reload or drop as needed. Returns true if anything changed.
758    fn sync_gitignores_from_disk(&mut self) -> bool {
759        let fs = self.authority.filesystem.clone();
760        let Some(explorer) = self.file_explorer.as_mut() else {
761            return false;
762        };
763        let dirs = explorer.ignore_patterns().loaded_gitignore_dirs();
764        let mut changed = false;
765        for dir in dirs {
766            let gitignore_path = dir.join(".gitignore");
767            match fs.metadata(&gitignore_path) {
768                Err(_) => {
769                    explorer.ignore_patterns_mut().remove_gitignore(&dir);
770                    changed = true;
771                }
772                Ok(meta) => {
773                    let stored = explorer.ignore_patterns().stored_gitignore_mtime(&dir);
774                    if stored != meta.modified {
775                        load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
776                        changed = true;
777                    }
778                }
779            }
780        }
781        changed
782    }
783
784    /// Resolve the path to `.git/index` via `git rev-parse --git-dir`.
785    /// Uses the `ProcessSpawner` so it works transparently on both local
786    /// and remote (SSH) filesystems.
787    fn resolve_git_index(&self) -> Option<PathBuf> {
788        let spawner = &self.authority.process_spawner;
789        let cwd = self.working_dir.to_string_lossy().to_string();
790
791        // ProcessSpawner is async — run it on the tokio runtime if available,
792        // otherwise fall back to blocking (should only happen in tests without
793        // a runtime).
794        let result = if let Some(ref rt) = self.tokio_runtime {
795            rt.block_on(spawner.spawn(
796                "git".to_string(),
797                vec!["rev-parse".to_string(), "--git-dir".to_string()],
798                Some(cwd),
799            ))
800        } else {
801            // No runtime — can't run async spawner. This shouldn't happen
802            // in production but can in minimal test setups.
803            return None;
804        };
805
806        let output = result.ok()?;
807        if output.exit_code != 0 {
808            return None;
809        }
810        let git_dir = output.stdout.trim();
811        let git_dir_path = if std::path::Path::new(git_dir).is_absolute() {
812            PathBuf::from(git_dir)
813        } else {
814            self.working_dir.join(git_dir)
815        };
816        Some(git_dir_path.join("index"))
817    }
818
819    /// Notify LSP server about a newly opened file
820    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
821    pub(crate) fn notify_lsp_file_opened(
822        &mut self,
823        path: &Path,
824        buffer_id: BufferId,
825        metadata: &mut BufferMetadata,
826    ) {
827        // Get language from buffer state
828        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
829            tracing::debug!("No buffer state for file: {}", path.display());
830            return;
831        };
832
833        let Some(uri) = metadata.file_uri().cloned() else {
834            tracing::warn!(
835                "No URI in metadata for file: {} (failed to compute absolute path)",
836                path.display()
837            );
838            return;
839        };
840
841        // Check file size
842        let file_size = self
843            .authority
844            .filesystem
845            .metadata(path)
846            .ok()
847            .map(|m| m.size)
848            .unwrap_or(0);
849        if file_size > self.config.editor.large_file_threshold_bytes {
850            let reason = format!("File too large ({} bytes)", file_size);
851            tracing::debug!(
852                "Skipping LSP for large file: {} ({})",
853                path.display(),
854                reason
855            );
856            metadata.disable_lsp(reason);
857            return;
858        }
859
860        // Get text before borrowing lsp
861        let text = match self
862            .buffers
863            .get(&buffer_id)
864            .and_then(|state| state.buffer.to_string())
865        {
866            Some(t) => t,
867            None => {
868                tracing::debug!("Buffer not fully loaded for LSP notification");
869                return;
870            }
871        };
872
873        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
874        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
875
876        // Get buffer line count and version for inlay hints
877        let (last_line, last_char, buffer_version) = self
878            .buffers
879            .get(&buffer_id)
880            .map(|state| {
881                let line_count = state.buffer.line_count().unwrap_or(1000);
882                (
883                    line_count.saturating_sub(1) as u32,
884                    10000u32,
885                    state.buffer.version(),
886                )
887            })
888            .unwrap_or((999, 10000, 0));
889
890        // Now borrow lsp and do all LSP operations
891        let Some(lsp) = &mut self.lsp else {
892            tracing::debug!("No LSP manager available");
893            return;
894        };
895
896        tracing::debug!("LSP manager available for file: {}", path.display());
897        tracing::debug!(
898            "Detected language: {} for file: {}",
899            language,
900            path.display()
901        );
902        tracing::debug!("Using URI from metadata: {}", uri.as_str());
903        tracing::debug!("Attempting to spawn LSP client for language: {}", language);
904
905        match lsp.try_spawn(&language, Some(path)) {
906            LspSpawnResult::Spawned => {
907                // Send didOpen to ALL server handles for this language,
908                // not just the first one.  With multiple servers configured
909                // (e.g. error-server + warning-server) each needs to know
910                // about the open document.
911                for sh in lsp.get_handles_mut(&language) {
912                    tracing::info!("Sending didOpen to LSP '{}' for: {}", sh.name, uri.as_str());
913                    if let Err(e) =
914                        sh.handle
915                            .did_open(uri.as_uri().clone(), text.clone(), language.clone())
916                    {
917                        tracing::warn!("Failed to send didOpen to LSP '{}': {}", sh.name, e);
918                    } else {
919                        metadata.lsp_opened_with.insert(sh.handle.id());
920                    }
921                }
922
923                // Route each follow-up request through capability-aware
924                // routing so we never send an optional method to a server
925                // that didn't advertise it. On a cold spawn the capability
926                // check returns `None` (capabilities aren't known until the
927                // `initialize` response arrives); the `LspInitialized`
928                // handler replays these requests once capabilities land.
929                if let Some(sh) =
930                    lsp.handle_for_feature_mut(&language, crate::types::LspFeature::Diagnostics)
931                {
932                    let request_id = self.next_lsp_request_id;
933                    self.next_lsp_request_id += 1;
934                    if let Err(e) = sh.handle.document_diagnostic(
935                        request_id,
936                        uri.as_uri().clone(),
937                        previous_result_id,
938                    ) {
939                        tracing::debug!("Failed to request pull diagnostics: {}", e);
940                    } else {
941                        tracing::info!(
942                            "Requested pull diagnostics for {} (request_id={})",
943                            uri.as_str(),
944                            request_id
945                        );
946                    }
947                }
948
949                if enable_inlay_hints {
950                    if let Some(sh) =
951                        lsp.handle_for_feature_mut(&language, crate::types::LspFeature::InlayHints)
952                    {
953                        let request_id = self.next_lsp_request_id;
954                        self.next_lsp_request_id += 1;
955
956                        if let Err(e) = sh.handle.inlay_hints(
957                            request_id,
958                            uri.as_uri().clone(),
959                            0,
960                            0,
961                            last_line,
962                            last_char,
963                        ) {
964                            tracing::debug!("Failed to request inlay hints: {}", e);
965                        } else {
966                            self.pending_inlay_hints_requests.insert(
967                                request_id,
968                                super::InlayHintsRequest {
969                                    buffer_id,
970                                    version: buffer_version,
971                                },
972                            );
973                            tracing::info!(
974                                "Requested inlay hints for {} (request_id={})",
975                                uri.as_str(),
976                                request_id
977                            );
978                        }
979                    }
980                }
981
982                // Schedule folding range refresh
983                self.schedule_folding_ranges_refresh(buffer_id);
984            }
985            LspSpawnResult::NotAutoStart => {
986                tracing::debug!(
987                    "LSP for {} not auto-starting (auto_start=false). Use command palette to start manually.",
988                    language
989                );
990                // Queue an auto-prompt for this language so the user
991                // can discover the dormant server (otherwise the only
992                // visible signal is a muted `LSP (off)` pill, which is
993                // easy to miss). We intentionally don't show the popup
994                // inline here — session restore typically opens many
995                // files of the same language back-to-back, and the
996                // buffer active at *this* instant isn't necessarily
997                // the one the user lands on. Draining happens on
998                // render, which guarantees the popup attaches to
999                // whichever buffer the user is actually looking at.
1000                //
1001                // Skip queueing entirely when the user already got
1002                // the prompt this session or dismissed the pill —
1003                // both mean "please don't re-pop this."  The
1004                // persisted `auto_start = true` flag is what
1005                // silences the prompt across sessions. Also skip
1006                // when the process-wide toggle is off — e2e tests
1007                // set this via `set_lsp_auto_prompt_enabled(false)`
1008                // in their ctor so the popup doesn't steal
1009                // keystrokes from unrelated scenarios.
1010                if self.lsp_auto_prompt_enabled
1011                    && !self.auto_start_prompted_languages.contains(&language)
1012                    && !self.is_lsp_language_user_dismissed(&language)
1013                {
1014                    self.pending_auto_start_prompts.insert(language);
1015                }
1016            }
1017            LspSpawnResult::NotConfigured => {
1018                tracing::debug!("No LSP server configured for language: {}", language);
1019            }
1020            LspSpawnResult::Disabled => {
1021                tracing::debug!("LSP disabled in config for language: {}", language);
1022            }
1023            LspSpawnResult::Failed => {
1024                tracing::warn!("Failed to spawn LSP client for language: {}", language);
1025            }
1026        }
1027    }
1028
1029    /// Record a file's modification time (called when opening files)
1030    /// This is used by the polling-based auto-revert to detect external changes
1031    pub(crate) fn watch_file(&mut self, path: &Path) {
1032        // Record current modification time for polling
1033        if let Ok(metadata) = self.authority.filesystem.metadata(path) {
1034            if let Some(mtime) = metadata.modified {
1035                self.file_mod_times.insert(path.to_path_buf(), mtime);
1036            }
1037        }
1038    }
1039
1040    /// Notify LSP that a file's contents changed (e.g., after revert)
1041    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
1042        use crate::services::lsp::manager::LspSpawnResult;
1043
1044        let Some(lsp_uri) = super::types::file_path_to_lsp_uri_with_translation(
1045            path,
1046            self.authority.path_translation.as_ref(),
1047        ) else {
1048            return;
1049        };
1050
1051        // Find the buffer ID, content, and language for this path
1052        let Some((buffer_id, content, language)) = self
1053            .buffers
1054            .iter()
1055            .find(|(_, s)| s.buffer.file_path() == Some(path))
1056            .and_then(|(id, state)| {
1057                state
1058                    .buffer
1059                    .to_string()
1060                    .map(|t| (*id, t, state.language.clone()))
1061            })
1062        else {
1063            return;
1064        };
1065
1066        // Check if we can spawn LSP (respects auto_start setting)
1067        let spawn_result = {
1068            let Some(lsp) = self.lsp.as_mut() else {
1069                return;
1070            };
1071            lsp.try_spawn(&language, Some(path))
1072        };
1073
1074        // Only proceed if spawned successfully (or already running)
1075        if spawn_result != LspSpawnResult::Spawned {
1076            return;
1077        }
1078
1079        // Send didOpen to any handles that haven't received it yet
1080        {
1081            let opened_with = self
1082                .buffer_metadata
1083                .get(&buffer_id)
1084                .map(|m| m.lsp_opened_with.clone())
1085                .unwrap_or_default();
1086
1087            if let Some(lsp) = self.lsp.as_mut() {
1088                for sh in lsp.get_handles_mut(&language) {
1089                    if opened_with.contains(&sh.handle.id()) {
1090                        continue;
1091                    }
1092                    if let Err(e) =
1093                        sh.handle
1094                            .did_open(lsp_uri.clone(), content.clone(), language.clone())
1095                    {
1096                        tracing::warn!(
1097                            "Failed to send didOpen to LSP '{}' before didChange: {}",
1098                            sh.name,
1099                            e
1100                        );
1101                    } else {
1102                        tracing::debug!(
1103                            "Sent didOpen for {} to LSP '{}' before file change notification",
1104                            lsp_uri.as_str(),
1105                            sh.name
1106                        );
1107                    }
1108                }
1109            }
1110
1111            // Mark all handles as opened
1112            if let Some(lsp) = self.lsp.as_ref() {
1113                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
1114                    for sh in lsp.get_handles(&language) {
1115                        metadata.lsp_opened_with.insert(sh.handle.id());
1116                    }
1117                }
1118            }
1119        }
1120
1121        // Use full document sync - broadcast to all handles
1122        if let Some(lsp) = &mut self.lsp {
1123            let content_change = TextDocumentContentChangeEvent {
1124                range: None, // None means full document replacement
1125                range_length: None,
1126                text: content,
1127            };
1128            for sh in lsp.get_handles_mut(&language) {
1129                if let Err(e) = sh
1130                    .handle
1131                    .did_change(lsp_uri.clone(), vec![content_change.clone()])
1132                {
1133                    tracing::warn!("Failed to notify LSP '{}' of file change: {}", sh.name, e);
1134                }
1135            }
1136        }
1137    }
1138
1139    /// Revert a specific buffer by ID without affecting the active viewport.
1140    ///
1141    /// This is used for auto-reverting background buffers that aren't currently
1142    /// visible in the active split. It reloads the buffer content and updates
1143    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
1144    pub(crate) fn revert_buffer_by_id(
1145        &mut self,
1146        buffer_id: BufferId,
1147        path: &Path,
1148    ) -> anyhow::Result<()> {
1149        // Preserve user settings before reloading
1150        // TODO: Consider moving line numbers to SplitViewState (per-view setting)
1151        // Get cursors from split view states for this buffer (find any split showing it)
1152        let old_cursors = self
1153            .split_view_states
1154            .values()
1155            .find_map(|vs| {
1156                if vs.keyed_states.contains_key(&buffer_id) {
1157                    vs.keyed_states.get(&buffer_id).map(|bs| bs.cursors.clone())
1158                } else {
1159                    None
1160                }
1161            })
1162            .unwrap_or_default();
1163        let (old_buffer_settings, old_editing_disabled) = self
1164            .buffers
1165            .get(&buffer_id)
1166            .map(|s| (s.buffer_settings.clone(), s.editing_disabled))
1167            .unwrap_or_default();
1168
1169        // Load the file content fresh from disk
1170        let mut new_state = EditorState::from_file_with_languages(
1171            path,
1172            self.terminal_width,
1173            self.terminal_height,
1174            self.config.editor.large_file_threshold_bytes as usize,
1175            &self.grammar_registry,
1176            &self.config.languages,
1177            std::sync::Arc::clone(&self.authority.filesystem),
1178        )?;
1179
1180        // Get the new file size for clamping
1181        let new_file_size = new_state.buffer.len();
1182
1183        // Restore cursor positions (clamped to valid range for new file size)
1184        let mut restored_cursors = old_cursors;
1185        restored_cursors.map(|cursor| {
1186            cursor.position = cursor.position.min(new_file_size);
1187            cursor.clear_selection();
1188        });
1189        // Restore user settings (tab size, indentation, etc.)
1190        new_state.buffer_settings = old_buffer_settings;
1191        new_state.editing_disabled = old_editing_disabled;
1192        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
1193
1194        // Replace the buffer content
1195        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1196            *state = new_state;
1197        }
1198
1199        // Restore cursors in any split view states that have this buffer
1200        for vs in self.split_view_states.values_mut() {
1201            if let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) {
1202                buf_state.cursors = restored_cursors.clone();
1203            }
1204        }
1205
1206        // Clear the undo/redo history for this buffer
1207        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1208            *event_log = EventLog::new();
1209        }
1210
1211        // Clear seen_byte_ranges so plugins get notified of all visible lines
1212        self.seen_byte_ranges.remove(&buffer_id);
1213
1214        // Update the file modification time
1215        if let Ok(metadata) = self.authority.filesystem.metadata(path) {
1216            if let Some(mtime) = metadata.modified {
1217                self.file_mod_times.insert(path.to_path_buf(), mtime);
1218            }
1219        }
1220
1221        // Notify LSP that the file was changed
1222        self.notify_lsp_file_changed(path);
1223
1224        Ok(())
1225    }
1226
1227    /// Handle a file change notification (from file watcher)
1228    pub fn handle_file_changed(&mut self, changed_path: &str) {
1229        let path = PathBuf::from(changed_path);
1230
1231        // Find buffers that have this file open
1232        let buffer_ids: Vec<BufferId> = self
1233            .buffers
1234            .iter()
1235            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
1236            .map(|(id, _)| *id)
1237            .collect();
1238
1239        if buffer_ids.is_empty() {
1240            return;
1241        }
1242
1243        for buffer_id in buffer_ids {
1244            // Skip terminal buffers - they manage their own content via PTY streaming
1245            // and should not be auto-reverted (which would reset editing_disabled and line_numbers)
1246            if self.terminal_buffers.contains_key(&buffer_id) {
1247                continue;
1248            }
1249
1250            let state = match self.buffers.get(&buffer_id) {
1251                Some(s) => s,
1252                None => continue,
1253            };
1254
1255            // Check if the file actually changed (compare mod times)
1256            // We use optimistic concurrency: check mtime, and if we decide to revert,
1257            // re-check to handle the race where a save completed between our checks.
1258            let current_mtime = match self
1259                .authority
1260                .filesystem
1261                .metadata(&path)
1262                .ok()
1263                .and_then(|m| m.modified)
1264            {
1265                Some(mtime) => mtime,
1266                None => continue, // Can't read file, skip
1267            };
1268
1269            let dominated_by_stored = self
1270                .file_mod_times
1271                .get(&path)
1272                .map(|stored| current_mtime <= *stored)
1273                .unwrap_or(false);
1274
1275            if dominated_by_stored {
1276                continue;
1277            }
1278
1279            // If buffer has local modifications, show a warning (don't auto-revert)
1280            if state.buffer.is_modified() {
1281                self.status_message = Some(format!(
1282                    "File {} changed on disk (buffer has unsaved changes)",
1283                    path.display()
1284                ));
1285                continue;
1286            }
1287
1288            // Auto-revert if enabled and buffer is not modified
1289            if self.auto_revert_enabled {
1290                // Optimistic concurrency: re-check mtime before reverting.
1291                // A save may have completed between our first check and now,
1292                // updating file_mod_times. If so, skip the revert.
1293                let still_needs_revert = self
1294                    .file_mod_times
1295                    .get(&path)
1296                    .map(|stored| current_mtime > *stored)
1297                    .unwrap_or(true);
1298
1299                if !still_needs_revert {
1300                    continue;
1301                }
1302
1303                // Check if this buffer is currently displayed in the active split
1304                let is_active_buffer = buffer_id == self.active_buffer();
1305
1306                if is_active_buffer {
1307                    // Use revert_file() which preserves viewport for active buffer
1308                    if let Err(e) = self.revert_file() {
1309                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
1310                    } else {
1311                        tracing::info!("Auto-reverted file: {:?}", path);
1312                    }
1313                } else {
1314                    // Use revert_buffer_by_id() which doesn't touch any viewport
1315                    // This prevents corrupting the active split's viewport state
1316                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
1317                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
1318                    } else {
1319                        tracing::info!("Auto-reverted file: {:?}", path);
1320                    }
1321                }
1322
1323                // Update the modification time tracking for this file
1324                self.watch_file(&path);
1325            }
1326        }
1327    }
1328
1329    /// Check if saving would overwrite changes made by another process
1330    /// Returns Some(current_mtime) if there's a conflict, None otherwise
1331    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
1332        let path = self.active_state().buffer.file_path()?;
1333
1334        // Get current file modification time
1335        let current_mtime = self
1336            .authority
1337            .filesystem
1338            .metadata(path)
1339            .ok()
1340            .and_then(|m| m.modified)?;
1341
1342        // Compare with our recorded modification time
1343        match self.file_mod_times.get(path) {
1344            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
1345                // File was modified externally since we last loaded/saved it
1346                Some(current_mtime)
1347            }
1348            _ => None,
1349        }
1350    }
1351}
1352
1353/// Stat and read `dir/.gitignore` via the filesystem authority and install
1354/// the result on `explorer`. No-op (with a warn-level log on unexpected
1355/// errors) when the file doesn't exist. Shared by the init, expand, save,
1356/// and poll paths so everything routes through the same authority.
1357pub(crate) fn load_gitignore_via_fs(fs: &dyn FileSystem, explorer: &mut FileTreeView, dir: &Path) {
1358    let gitignore_path = dir.join(".gitignore");
1359    let meta = match fs.metadata(&gitignore_path) {
1360        Ok(m) => m,
1361        Err(_) => return,
1362    };
1363    let bytes = match fs.read_file(&gitignore_path) {
1364        Ok(b) => b,
1365        Err(e) => {
1366            tracing::warn!("Failed to read {:?}: {}", gitignore_path, e);
1367            return;
1368        }
1369    };
1370    explorer.load_gitignore_from_bytes(dir, &bytes, meta.modified);
1371}