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