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::view::prompt::PromptType;
13use std::path::{Path, PathBuf};
14
15use lsp_types::TextDocumentContentChangeEvent;
16use rust_i18n::t;
17
18use crate::model::event::{BufferId, EventLog};
19use crate::services::lsp::manager::LspSpawnResult;
20use crate::state::EditorState;
21
22use super::{BufferMetadata, Editor};
23
24impl Editor {
25    /// Save the active buffer
26    pub fn save(&mut self) -> anyhow::Result<()> {
27        let path = self
28            .active_state()
29            .buffer
30            .file_path()
31            .map(|p| p.to_path_buf());
32
33        match self.active_state_mut().buffer.save() {
34            Ok(()) => self.finalize_save(path),
35            Err(e) => {
36                if let Some(sudo_info) = e.downcast_ref::<SudoSaveRequired>() {
37                    let info = sudo_info.clone();
38                    self.start_prompt(
39                        t!("prompt.sudo_save_confirm").to_string(),
40                        PromptType::ConfirmSudoSave { info },
41                    );
42                    Ok(())
43                } else {
44                    Err(e)
45                }
46            }
47        }
48    }
49
50    /// Internal helper to finalize save state (mark as saved, notify LSP, etc.)
51    pub(crate) fn finalize_save(&mut self, path: Option<PathBuf>) -> anyhow::Result<()> {
52        let buffer_id = self.active_buffer();
53        self.finalize_save_buffer(buffer_id, path, false)
54    }
55
56    /// Internal helper to finalize save state for a specific buffer
57    pub(crate) fn finalize_save_buffer(
58        &mut self,
59        buffer_id: BufferId,
60        path: Option<PathBuf>,
61        silent: bool,
62    ) -> anyhow::Result<()> {
63        // Auto-detect language if it's currently "text" and we have a path
64        if let Some(ref p) = path {
65            if let Some(state) = self.buffers.get_mut(&buffer_id) {
66                if state.language == "text" {
67                    let detected =
68                        crate::primitives::detected_language::DetectedLanguage::from_path(
69                            p,
70                            &self.grammar_registry,
71                            &self.config.languages,
72                        );
73                    state.apply_language(detected);
74                }
75            }
76        }
77
78        if !silent {
79            self.status_message = Some(t!("status.file_saved").to_string());
80        }
81
82        // Mark the event log position as saved (for undo modified tracking)
83        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
84            event_log.mark_saved();
85        }
86
87        // Update file modification time after save
88        if let Some(ref p) = path {
89            if let Ok(metadata) = self.filesystem.metadata(p) {
90                if let Some(mtime) = metadata.modified {
91                    self.file_mod_times.insert(p.clone(), mtime);
92                }
93            }
94        }
95
96        // Notify LSP of save
97        self.notify_lsp_save_buffer(buffer_id);
98
99        // Delete recovery file (buffer is now saved)
100        if let Err(e) = self.delete_buffer_recovery(buffer_id) {
101            tracing::warn!("Failed to delete recovery file: {}", e);
102        }
103
104        // Emit control event
105        if let Some(ref p) = path {
106            self.emit_event(
107                crate::model::control_event::events::FILE_SAVED.name,
108                serde_json::json!({
109                    "path": p.display().to_string()
110                }),
111            );
112        }
113
114        // Fire AfterFileSave hook for plugins
115        if let Some(ref p) = path {
116            self.plugin_manager.run_hook(
117                "after_file_save",
118                crate::services::plugins::hooks::HookArgs::AfterFileSave {
119                    buffer_id,
120                    path: p.clone(),
121                },
122            );
123        }
124
125        // Run on-save actions (formatters, linters, etc.)
126        // Note: run_on_save_actions also assumes active_buffer internally.
127        // We might need to refactor it too if we want auto-save to trigger formatters.
128        // For now, let's just do it for active buffer or skip for silent auto-saves.
129
130        if !silent {
131            match self.run_on_save_actions() {
132                Ok(true) => {
133                    // Actions ran successfully - if status_message was set by run_on_save_actions
134                    // (e.g., for missing optional formatters), keep it. Otherwise update status.
135                    if self.status_message.as_deref() == Some(&t!("status.file_saved")) {
136                        self.status_message =
137                            Some(t!("status.file_saved_with_actions").to_string());
138                    }
139                    // else: keep the message set by run_on_save_actions (e.g., missing formatter)
140                }
141                Ok(false) => {
142                    // No actions configured, keep original status
143                }
144                Err(e) => {
145                    // Action failed, show error but don't fail the save
146                    self.status_message = Some(e);
147                }
148            }
149        }
150
151        Ok(())
152    }
153
154    /// Auto-save all modified buffers to their original files on disk
155    /// Returns the number of buffers saved
156    pub fn auto_save_persistent_buffers(&mut self) -> anyhow::Result<usize> {
157        if !self.config.editor.auto_save_enabled {
158            return Ok(0);
159        }
160
161        // Check if enough time has passed since last auto-save
162        let interval =
163            std::time::Duration::from_secs(self.config.editor.auto_save_interval_secs as u64);
164        if self
165            .time_source
166            .elapsed_since(self.last_persistent_auto_save)
167            < interval
168        {
169            return Ok(0);
170        }
171
172        self.last_persistent_auto_save = self.time_source.now();
173
174        // Collect info for modified buffers that have a file path
175        let mut to_save = Vec::new();
176        for (id, state) in &self.buffers {
177            if state.buffer.is_modified() {
178                if let Some(path) = state.buffer.file_path() {
179                    to_save.push((*id, path.to_path_buf()));
180                }
181            }
182        }
183
184        let mut count = 0;
185        for (id, path) in to_save {
186            if let Some(state) = self.buffers.get_mut(&id) {
187                match state.buffer.save() {
188                    Ok(()) => {
189                        self.finalize_save_buffer(id, Some(path), true)?;
190                        count += 1;
191                    }
192                    Err(e) => {
193                        // Skip if sudo is required (auto-save can't handle prompts)
194                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
195                            tracing::debug!(
196                                "Auto-save skipped for {:?} (sudo required)",
197                                path.display()
198                            );
199                        } else {
200                            tracing::warn!("Auto-save failed for {:?}: {}", path.display(), e);
201                        }
202                    }
203                }
204            }
205        }
206
207        Ok(count)
208    }
209
210    /// Save all modified file-backed buffers to disk (called on exit when auto_save is enabled).
211    /// Unlike `auto_save_persistent_buffers`, this skips the interval check and only saves
212    /// named file-backed buffers (not unnamed buffers).
213    pub fn save_all_on_exit(&mut self) -> anyhow::Result<usize> {
214        let mut to_save = Vec::new();
215        for (id, state) in &self.buffers {
216            if state.buffer.is_modified() {
217                if let Some(path) = state.buffer.file_path() {
218                    if !path.as_os_str().is_empty() {
219                        to_save.push((*id, path.to_path_buf()));
220                    }
221                }
222            }
223        }
224
225        let mut count = 0;
226        for (id, path) in to_save {
227            if let Some(state) = self.buffers.get_mut(&id) {
228                match state.buffer.save() {
229                    Ok(()) => {
230                        self.finalize_save_buffer(id, Some(path), true)?;
231                        count += 1;
232                    }
233                    Err(e) => {
234                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
235                            tracing::debug!(
236                                "Auto-save on exit skipped for {} (sudo required)",
237                                path.display()
238                            );
239                        } else {
240                            tracing::warn!(
241                                "Auto-save on exit failed for {}: {}",
242                                path.display(),
243                                e
244                            );
245                        }
246                    }
247                }
248            }
249        }
250
251        Ok(count)
252    }
253
254    /// Revert the active buffer to the last saved version on disk
255    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
256    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
257        let path = match self.active_state().buffer.file_path() {
258            Some(p) => p.to_path_buf(),
259            None => {
260                self.status_message = Some(t!("status.no_file_to_revert").to_string());
261                return Ok(false);
262            }
263        };
264
265        if !path.exists() {
266            self.status_message =
267                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
268            return Ok(false);
269        }
270
271        // Save scroll position (from SplitViewState) and cursor positions before reloading
272        let active_split = self.split_manager.active_split();
273        let (old_top_byte, old_left_column) = self
274            .split_view_states
275            .get(&active_split)
276            .map(|vs| (vs.viewport.top_byte, vs.viewport.left_column))
277            .unwrap_or((0, 0));
278        let old_cursors = self.active_cursors().clone();
279
280        // Preserve user settings before reloading
281        let old_buffer_settings = self.active_state().buffer_settings.clone();
282        let old_editing_disabled = self.active_state().editing_disabled;
283
284        // Load the file content fresh from disk
285        let mut new_state = EditorState::from_file_with_languages(
286            &path,
287            self.terminal_width,
288            self.terminal_height,
289            self.config.editor.large_file_threshold_bytes as usize,
290            &self.grammar_registry,
291            &self.config.languages,
292            std::sync::Arc::clone(&self.filesystem),
293        )?;
294
295        // Restore cursor positions (clamped to valid range for new file size)
296        let new_file_size = new_state.buffer.len();
297        let mut restored_cursors = old_cursors;
298        restored_cursors.map(|cursor| {
299            cursor.position = cursor.position.min(new_file_size);
300            // Clear selection since the content may have changed
301            cursor.clear_selection();
302        });
303        // Restore user settings (tab size, indentation, etc.)
304        new_state.buffer_settings = old_buffer_settings;
305        new_state.editing_disabled = old_editing_disabled;
306        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
307
308        // Replace the current buffer with the new state
309        let buffer_id = self.active_buffer();
310        if let Some(state) = self.buffers.get_mut(&buffer_id) {
311            *state = new_state;
312            // Note: line_wrap_enabled is now in SplitViewState.viewport
313        }
314
315        // Restore cursor positions in SplitViewState (clamped to valid range for new file size)
316        let active_split = self.split_manager.active_split();
317        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
318            view_state.cursors = restored_cursors;
319        }
320
321        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
322        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
323            view_state.viewport.top_byte = old_top_byte.min(new_file_size);
324            view_state.viewport.left_column = old_left_column;
325        }
326
327        // Clear the undo/redo history for this buffer
328        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
329            *event_log = EventLog::new();
330        }
331
332        // Clear seen_byte_ranges so plugins get notified of all visible lines
333        self.seen_byte_ranges.remove(&buffer_id);
334
335        // Update the file modification time
336        if let Ok(metadata) = self.filesystem.metadata(&path) {
337            if let Some(mtime) = metadata.modified {
338                self.file_mod_times.insert(path.clone(), mtime);
339            }
340        }
341
342        // Notify LSP that the file was changed
343        self.notify_lsp_file_changed(&path);
344
345        self.status_message = Some(t!("status.reverted").to_string());
346        Ok(true)
347    }
348
349    /// Toggle auto-revert mode
350    pub fn toggle_auto_revert(&mut self) {
351        self.auto_revert_enabled = !self.auto_revert_enabled;
352
353        if self.auto_revert_enabled {
354            self.status_message = Some(t!("status.auto_revert_enabled").to_string());
355        } else {
356            self.status_message = Some(t!("status.auto_revert_disabled").to_string());
357        }
358    }
359
360    /// Poll for file changes (called from main loop)
361    ///
362    /// Checks modification times of open files to detect external changes.
363    /// Returns true if any file was changed (requires re-render).
364    pub fn poll_file_changes(&mut self) -> bool {
365        // Skip if auto-revert is disabled
366        if !self.auto_revert_enabled {
367            return false;
368        }
369
370        // Check poll interval
371        let poll_interval =
372            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
373        let elapsed = self.time_source.elapsed_since(self.last_auto_revert_poll);
374        tracing::trace!(
375            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
376            elapsed,
377            poll_interval
378        );
379        if elapsed < poll_interval {
380            return false;
381        }
382        self.last_auto_revert_poll = self.time_source.now();
383
384        // Collect paths of open files that need checking
385        let files_to_check: Vec<PathBuf> = self
386            .buffers
387            .values()
388            .filter_map(|state| state.buffer.file_path().map(PathBuf::from))
389            .collect();
390
391        let mut any_changed = false;
392
393        for path in files_to_check {
394            // Get current mtime
395            let current_mtime = match self.filesystem.metadata(&path) {
396                Ok(meta) => match meta.modified {
397                    Some(mtime) => mtime,
398                    None => continue,
399                },
400                Err(_) => continue, // File might have been deleted
401            };
402
403            // Check if mtime has changed
404            if let Some(&stored_mtime) = self.file_mod_times.get(&path) {
405                if current_mtime != stored_mtime {
406                    // Handle the file change (this includes debouncing)
407                    // Note: file_mod_times is updated by handle_file_changed after successful revert,
408                    // not here, to avoid the race where the revert check sees the already-updated mtime
409                    let path_str = path.display().to_string();
410                    if self.handle_async_file_changed(path_str) {
411                        any_changed = true;
412                    }
413                }
414            } else {
415                // First time seeing this file, record its mtime
416                self.file_mod_times.insert(path, current_mtime);
417            }
418        }
419
420        any_changed
421    }
422
423    /// Poll for file tree changes (called from main loop)
424    ///
425    /// Checks modification times of expanded directories to detect new/deleted files.
426    /// Returns true if any directory was refreshed (requires re-render).
427    pub fn poll_file_tree_changes(&mut self) -> bool {
428        // Check poll interval
429        let poll_interval =
430            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
431        if self.time_source.elapsed_since(self.last_file_tree_poll) < poll_interval {
432            return false;
433        }
434        self.last_file_tree_poll = self.time_source.now();
435
436        // Get file explorer reference
437        let Some(explorer) = &self.file_explorer else {
438            return false;
439        };
440
441        // Collect expanded directories (node_id, path)
442        use crate::view::file_tree::NodeId;
443        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
444            .tree()
445            .all_nodes()
446            .filter(|node| node.is_dir() && node.is_expanded())
447            .map(|node| (node.id, node.entry.path.clone()))
448            .collect();
449
450        // Check mtimes and collect directories that need refresh
451        let mut dirs_to_refresh: Vec<NodeId> = Vec::new();
452
453        for (node_id, path) in expanded_dirs {
454            // Get current mtime
455            let current_mtime = match self.filesystem.metadata(&path) {
456                Ok(meta) => match meta.modified {
457                    Some(mtime) => mtime,
458                    None => continue,
459                },
460                Err(_) => continue, // Directory might have been deleted
461            };
462
463            // Check if mtime has changed
464            if let Some(&stored_mtime) = self.dir_mod_times.get(&path) {
465                if current_mtime != stored_mtime {
466                    // Update stored mtime
467                    self.dir_mod_times.insert(path.clone(), current_mtime);
468                    dirs_to_refresh.push(node_id);
469                    tracing::debug!("Directory changed: {:?}", path);
470                }
471            } else {
472                // First time seeing this directory, record its mtime
473                self.dir_mod_times.insert(path, current_mtime);
474            }
475        }
476
477        // Check .git/index mtime to detect commits, staging, checkouts, etc.
478        // Uses the same dir_mod_times map and poll interval as directory polling.
479        let git_index_changed = self.check_git_index_mtime();
480
481        // Refresh changed directories
482        if dirs_to_refresh.is_empty() && !git_index_changed {
483            return false;
484        }
485
486        // Refresh each changed directory
487        if let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) {
488            for node_id in dirs_to_refresh {
489                let tree = explorer.tree_mut();
490                if let Err(e) = runtime.block_on(tree.refresh_node(node_id)) {
491                    tracing::warn!("Failed to refresh directory: {}", e);
492                }
493            }
494        }
495
496        true
497    }
498
499    /// Check `.git/index` mtime to detect commits, staging, checkouts, etc.
500    /// Resolves the git dir once via `git rev-parse`, then tracks the index file's
501    /// mtime in `dir_mod_times` (the same map used for directory polling).
502    /// Returns true if the index mtime changed.
503    fn check_git_index_mtime(&mut self) -> bool {
504        // Resolve the git index path once and add it to dir_mod_times.
505        if !self.git_index_resolved {
506            self.git_index_resolved = true;
507            if let Some(path) = Self::resolve_git_index(&self.working_dir) {
508                if let Ok(mtime) = std::fs::metadata(&path).and_then(|m| m.modified()) {
509                    self.dir_mod_times.insert(path, mtime);
510                }
511            }
512            return false; // First poll — just record, don't trigger
513        }
514
515        // Find the git index entry in dir_mod_times and check for mtime change.
516        // There is at most one such entry.
517        let git_index_path = self
518            .dir_mod_times
519            .keys()
520            .find(|p| p.ends_with(".git/index") || p.ends_with(".git\\index"))
521            .cloned();
522
523        let Some(path) = git_index_path else {
524            return false; // Not a git repo
525        };
526
527        let current_mtime = match std::fs::metadata(&path).and_then(|m| m.modified()) {
528            Ok(mtime) => mtime,
529            Err(_) => return false,
530        };
531
532        let stored_mtime = self.dir_mod_times[&path];
533        if current_mtime != stored_mtime {
534            self.dir_mod_times.insert(path, current_mtime);
535            self.plugin_manager.run_hook(
536                "focus_gained",
537                crate::services::plugins::hooks::HookArgs::FocusGained,
538            );
539            return true;
540        }
541
542        false
543    }
544
545    /// Resolve the path to `.git/index` via `git rev-parse --git-dir`.
546    /// Works in regular repos, worktrees, submodules, and subdirectories.
547    fn resolve_git_index(working_dir: &Path) -> Option<PathBuf> {
548        let output = std::process::Command::new("git")
549            .args(["rev-parse", "--git-dir"])
550            .current_dir(working_dir)
551            .output()
552            .ok()?;
553        if !output.status.success() {
554            return None;
555        }
556        let git_dir = std::str::from_utf8(&output.stdout).ok()?.trim();
557        let git_dir_path = if std::path::Path::new(git_dir).is_absolute() {
558            PathBuf::from(git_dir)
559        } else {
560            working_dir.join(git_dir)
561        };
562        Some(git_dir_path.join("index"))
563    }
564
565    /// Notify LSP server about a newly opened file
566    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
567    pub(crate) fn notify_lsp_file_opened(
568        &mut self,
569        path: &Path,
570        buffer_id: BufferId,
571        metadata: &mut BufferMetadata,
572    ) {
573        // Get language from buffer state
574        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
575            tracing::debug!("No buffer state for file: {}", path.display());
576            return;
577        };
578
579        let Some(uri) = metadata.file_uri().cloned() else {
580            tracing::warn!(
581                "No URI in metadata for file: {} (failed to compute absolute path)",
582                path.display()
583            );
584            return;
585        };
586
587        // Check file size
588        let file_size = self
589            .filesystem
590            .metadata(path)
591            .ok()
592            .map(|m| m.size)
593            .unwrap_or(0);
594        if file_size > self.config.editor.large_file_threshold_bytes {
595            let reason = format!("File too large ({} bytes)", file_size);
596            tracing::warn!(
597                "Skipping LSP for large file: {} ({})",
598                path.display(),
599                reason
600            );
601            metadata.disable_lsp(reason);
602            return;
603        }
604
605        // Get text before borrowing lsp
606        let text = match self
607            .buffers
608            .get(&buffer_id)
609            .and_then(|state| state.buffer.to_string())
610        {
611            Some(t) => t,
612            None => {
613                tracing::debug!("Buffer not fully loaded for LSP notification");
614                return;
615            }
616        };
617
618        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
619        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
620
621        // Get buffer line count for inlay hints
622        let (last_line, last_char) = self
623            .buffers
624            .get(&buffer_id)
625            .map(|state| {
626                let line_count = state.buffer.line_count().unwrap_or(1000);
627                (line_count.saturating_sub(1) as u32, 10000u32)
628            })
629            .unwrap_or((999, 10000));
630
631        // Now borrow lsp and do all LSP operations
632        let Some(lsp) = &mut self.lsp else {
633            tracing::debug!("No LSP manager available");
634            return;
635        };
636
637        tracing::debug!("LSP manager available for file: {}", path.display());
638        tracing::debug!(
639            "Detected language: {} for file: {}",
640            language,
641            path.display()
642        );
643        tracing::debug!("Using URI from metadata: {}", uri.as_str());
644        tracing::debug!("Attempting to spawn LSP client for language: {}", language);
645
646        match lsp.try_spawn(&language, Some(path)) {
647            LspSpawnResult::Spawned => {
648                if let Some(client) = lsp.get_handle_mut(&language) {
649                    // Send didOpen
650                    tracing::info!("Sending didOpen to LSP for: {}", uri.as_str());
651                    if let Err(e) = client.did_open(uri.clone(), text, language.clone()) {
652                        tracing::warn!("Failed to send didOpen to LSP: {}", e);
653                        return;
654                    }
655                    tracing::info!("Successfully sent didOpen to LSP");
656
657                    // Mark this buffer as opened with this server instance
658                    metadata.lsp_opened_with.insert(client.id());
659
660                    // Request pull diagnostics
661                    let request_id = self.next_lsp_request_id;
662                    self.next_lsp_request_id += 1;
663                    if let Err(e) =
664                        client.document_diagnostic(request_id, uri.clone(), previous_result_id)
665                    {
666                        tracing::debug!(
667                            "Failed to request pull diagnostics (server may not support): {}",
668                            e
669                        );
670                    } else {
671                        tracing::info!(
672                            "Requested pull diagnostics for {} (request_id={})",
673                            uri.as_str(),
674                            request_id
675                        );
676                    }
677
678                    // Request inlay hints
679                    if enable_inlay_hints {
680                        let request_id = self.next_lsp_request_id;
681                        self.next_lsp_request_id += 1;
682                        self.pending_inlay_hints_request = Some(request_id);
683
684                        if let Err(e) =
685                            client.inlay_hints(request_id, uri.clone(), 0, 0, last_line, last_char)
686                        {
687                            tracing::debug!(
688                                "Failed to request inlay hints (server may not support): {}",
689                                e
690                            );
691                            self.pending_inlay_hints_request = None;
692                        } else {
693                            tracing::info!(
694                                "Requested inlay hints for {} (request_id={})",
695                                uri.as_str(),
696                                request_id
697                            );
698                        }
699                    }
700
701                    // Schedule folding range refresh
702                    self.schedule_folding_ranges_refresh(buffer_id);
703                }
704            }
705            LspSpawnResult::NotAutoStart => {
706                tracing::debug!(
707                    "LSP for {} not auto-starting (auto_start=false). Use command palette to start manually.",
708                    language
709                );
710            }
711            LspSpawnResult::NotConfigured => {
712                tracing::debug!("No LSP server configured for language: {}", language);
713            }
714            LspSpawnResult::Failed => {
715                tracing::warn!("Failed to spawn LSP client for language: {}", language);
716            }
717        }
718    }
719
720    /// Record a file's modification time (called when opening files)
721    /// This is used by the polling-based auto-revert to detect external changes
722    pub(crate) fn watch_file(&mut self, path: &Path) {
723        // Record current modification time for polling
724        if let Ok(metadata) = self.filesystem.metadata(path) {
725            if let Some(mtime) = metadata.modified {
726                self.file_mod_times.insert(path.to_path_buf(), mtime);
727            }
728        }
729    }
730
731    /// Notify LSP that a file's contents changed (e.g., after revert)
732    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
733        use crate::services::lsp::manager::LspSpawnResult;
734
735        let Some(lsp_uri) = super::types::file_path_to_lsp_uri(path) else {
736            return;
737        };
738
739        // Find the buffer ID, content, and language for this path
740        let Some((buffer_id, content, language)) = self
741            .buffers
742            .iter()
743            .find(|(_, s)| s.buffer.file_path() == Some(path))
744            .and_then(|(id, state)| {
745                state
746                    .buffer
747                    .to_string()
748                    .map(|t| (*id, t, state.language.clone()))
749            })
750        else {
751            return;
752        };
753
754        // Check if we can spawn LSP (respects auto_start setting)
755        let spawn_result = {
756            let Some(lsp) = self.lsp.as_mut() else {
757                return;
758            };
759            lsp.try_spawn(&language, Some(path))
760        };
761
762        // Only proceed if spawned successfully (or already running)
763        if spawn_result != LspSpawnResult::Spawned {
764            return;
765        }
766
767        // Get handle ID (handle should exist now since try_spawn succeeded)
768        let handle_id = {
769            let Some(lsp) = self.lsp.as_mut() else {
770                return;
771            };
772            let Some(handle) = lsp.get_handle_mut(&language) else {
773                return;
774            };
775            handle.id()
776        };
777
778        // Check if didOpen needs to be sent first
779        let needs_open = {
780            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
781                return;
782            };
783            !metadata.lsp_opened_with.contains(&handle_id)
784        };
785
786        if needs_open {
787            // Send didOpen first
788            if let Some(lsp) = self.lsp.as_mut() {
789                if let Some(handle) = lsp.get_handle_mut(&language) {
790                    if let Err(e) =
791                        handle.did_open(lsp_uri.clone(), content.clone(), language.clone())
792                    {
793                        tracing::warn!("Failed to send didOpen before didChange: {}", e);
794                        return;
795                    }
796                    tracing::debug!(
797                        "Sent didOpen for {} to LSP handle {} before file change notification",
798                        lsp_uri.as_str(),
799                        handle_id
800                    );
801                }
802            }
803
804            // Mark as opened
805            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
806                metadata.lsp_opened_with.insert(handle_id);
807            }
808        }
809
810        // Use full document sync - broadcast to all handles
811        if let Some(lsp) = &mut self.lsp {
812            let content_change = TextDocumentContentChangeEvent {
813                range: None, // None means full document replacement
814                range_length: None,
815                text: content,
816            };
817            for sh in lsp.get_handles_mut(&language) {
818                if let Err(e) = sh
819                    .handle
820                    .did_change(lsp_uri.clone(), vec![content_change.clone()])
821                {
822                    tracing::warn!("Failed to notify LSP '{}' of file change: {}", sh.name, e);
823                }
824            }
825        }
826    }
827
828    /// Revert a specific buffer by ID without affecting the active viewport.
829    ///
830    /// This is used for auto-reverting background buffers that aren't currently
831    /// visible in the active split. It reloads the buffer content and updates
832    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
833    pub(crate) fn revert_buffer_by_id(
834        &mut self,
835        buffer_id: BufferId,
836        path: &Path,
837    ) -> anyhow::Result<()> {
838        // Preserve user settings before reloading
839        // TODO: Consider moving line numbers to SplitViewState (per-view setting)
840        // Get cursors from split view states for this buffer (find any split showing it)
841        let old_cursors = self
842            .split_view_states
843            .values()
844            .find_map(|vs| {
845                if vs.keyed_states.contains_key(&buffer_id) {
846                    vs.keyed_states.get(&buffer_id).map(|bs| bs.cursors.clone())
847                } else {
848                    None
849                }
850            })
851            .unwrap_or_default();
852        let (old_buffer_settings, old_editing_disabled) = self
853            .buffers
854            .get(&buffer_id)
855            .map(|s| (s.buffer_settings.clone(), s.editing_disabled))
856            .unwrap_or_default();
857
858        // Load the file content fresh from disk
859        let mut new_state = EditorState::from_file_with_languages(
860            path,
861            self.terminal_width,
862            self.terminal_height,
863            self.config.editor.large_file_threshold_bytes as usize,
864            &self.grammar_registry,
865            &self.config.languages,
866            std::sync::Arc::clone(&self.filesystem),
867        )?;
868
869        // Get the new file size for clamping
870        let new_file_size = new_state.buffer.len();
871
872        // Restore cursor positions (clamped to valid range for new file size)
873        let mut restored_cursors = old_cursors;
874        restored_cursors.map(|cursor| {
875            cursor.position = cursor.position.min(new_file_size);
876            cursor.clear_selection();
877        });
878        // Restore user settings (tab size, indentation, etc.)
879        new_state.buffer_settings = old_buffer_settings;
880        new_state.editing_disabled = old_editing_disabled;
881        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
882
883        // Replace the buffer content
884        if let Some(state) = self.buffers.get_mut(&buffer_id) {
885            *state = new_state;
886        }
887
888        // Restore cursors in any split view states that have this buffer
889        for vs in self.split_view_states.values_mut() {
890            if let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) {
891                buf_state.cursors = restored_cursors.clone();
892            }
893        }
894
895        // Clear the undo/redo history for this buffer
896        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
897            *event_log = EventLog::new();
898        }
899
900        // Clear seen_byte_ranges so plugins get notified of all visible lines
901        self.seen_byte_ranges.remove(&buffer_id);
902
903        // Update the file modification time
904        if let Ok(metadata) = self.filesystem.metadata(path) {
905            if let Some(mtime) = metadata.modified {
906                self.file_mod_times.insert(path.to_path_buf(), mtime);
907            }
908        }
909
910        // Notify LSP that the file was changed
911        self.notify_lsp_file_changed(path);
912
913        Ok(())
914    }
915
916    /// Handle a file change notification (from file watcher)
917    pub fn handle_file_changed(&mut self, changed_path: &str) {
918        let path = PathBuf::from(changed_path);
919
920        // Find buffers that have this file open
921        let buffer_ids: Vec<BufferId> = self
922            .buffers
923            .iter()
924            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
925            .map(|(id, _)| *id)
926            .collect();
927
928        if buffer_ids.is_empty() {
929            return;
930        }
931
932        for buffer_id in buffer_ids {
933            // Skip terminal buffers - they manage their own content via PTY streaming
934            // and should not be auto-reverted (which would reset editing_disabled and line_numbers)
935            if self.terminal_buffers.contains_key(&buffer_id) {
936                continue;
937            }
938
939            let state = match self.buffers.get(&buffer_id) {
940                Some(s) => s,
941                None => continue,
942            };
943
944            // Check if the file actually changed (compare mod times)
945            // We use optimistic concurrency: check mtime, and if we decide to revert,
946            // re-check to handle the race where a save completed between our checks.
947            let current_mtime = match self
948                .filesystem
949                .metadata(&path)
950                .ok()
951                .and_then(|m| m.modified)
952            {
953                Some(mtime) => mtime,
954                None => continue, // Can't read file, skip
955            };
956
957            let dominated_by_stored = self
958                .file_mod_times
959                .get(&path)
960                .map(|stored| current_mtime <= *stored)
961                .unwrap_or(false);
962
963            if dominated_by_stored {
964                continue;
965            }
966
967            // If buffer has local modifications, show a warning (don't auto-revert)
968            if state.buffer.is_modified() {
969                self.status_message = Some(format!(
970                    "File {} changed on disk (buffer has unsaved changes)",
971                    path.display()
972                ));
973                continue;
974            }
975
976            // Auto-revert if enabled and buffer is not modified
977            if self.auto_revert_enabled {
978                // Optimistic concurrency: re-check mtime before reverting.
979                // A save may have completed between our first check and now,
980                // updating file_mod_times. If so, skip the revert.
981                let still_needs_revert = self
982                    .file_mod_times
983                    .get(&path)
984                    .map(|stored| current_mtime > *stored)
985                    .unwrap_or(true);
986
987                if !still_needs_revert {
988                    continue;
989                }
990
991                // Check if this buffer is currently displayed in the active split
992                let is_active_buffer = buffer_id == self.active_buffer();
993
994                if is_active_buffer {
995                    // Use revert_file() which preserves viewport for active buffer
996                    if let Err(e) = self.revert_file() {
997                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
998                    } else {
999                        tracing::info!("Auto-reverted file: {:?}", path);
1000                    }
1001                } else {
1002                    // Use revert_buffer_by_id() which doesn't touch any viewport
1003                    // This prevents corrupting the active split's viewport state
1004                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
1005                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
1006                    } else {
1007                        tracing::info!("Auto-reverted file: {:?}", path);
1008                    }
1009                }
1010
1011                // Update the modification time tracking for this file
1012                self.watch_file(&path);
1013            }
1014        }
1015    }
1016
1017    /// Check if saving would overwrite changes made by another process
1018    /// Returns Some(current_mtime) if there's a conflict, None otherwise
1019    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
1020        let path = self.active_state().buffer.file_path()?;
1021
1022        // Get current file modification time
1023        let current_mtime = self
1024            .filesystem
1025            .metadata(path)
1026            .ok()
1027            .and_then(|m| m.modified)?;
1028
1029        // Compare with our recorded modification time
1030        match self.file_mod_times.get(path) {
1031            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
1032                // File was modified externally since we last loaded/saved it
1033                Some(current_mtime)
1034            }
1035            _ => None,
1036        }
1037    }
1038}