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::{detect_language, 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        // Auto-detect language if it's currently "text" and we have a path
53        if let Some(ref p) = path {
54            let buffer_id = self.active_buffer();
55            if let Some(state) = self.buffers.get_mut(&buffer_id) {
56                if state.language == "text" {
57                    if let Some(filename) = p.file_name().and_then(|n| n.to_str()) {
58                        state.set_language_from_name(filename, &self.grammar_registry);
59                    }
60                }
61            }
62        }
63
64        self.status_message = Some(t!("status.file_saved").to_string());
65
66        // Mark the event log position as saved (for undo modified tracking)
67        self.active_event_log_mut().mark_saved();
68
69        // Update file modification time after save
70        if let Some(ref p) = path {
71            if let Ok(metadata) = self.filesystem.metadata(p) {
72                if let Some(mtime) = metadata.modified {
73                    self.file_mod_times.insert(p.clone(), mtime);
74                }
75            }
76        }
77
78        // Notify LSP of save
79        self.notify_lsp_save();
80
81        // Delete recovery file (buffer is now saved)
82        let _ = self.delete_buffer_recovery(self.active_buffer());
83
84        // Emit control event
85        if let Some(ref p) = path {
86            self.emit_event(
87                crate::model::control_event::events::FILE_SAVED.name,
88                serde_json::json!({
89                    "path": p.display().to_string()
90                }),
91            );
92        }
93
94        // Fire AfterFileSave hook for plugins
95        if let Some(ref p) = path {
96            let buffer_id = self.active_buffer();
97            self.plugin_manager.run_hook(
98                "after_file_save",
99                crate::services::plugins::hooks::HookArgs::AfterFileSave {
100                    buffer_id,
101                    path: p.clone(),
102                },
103            );
104        }
105
106        // Run on-save actions (formatters, linters, etc.)
107        match self.run_on_save_actions() {
108            Ok(true) => {
109                // Actions ran successfully - if status_message was set by run_on_save_actions
110                // (e.g., for missing optional formatters), keep it. Otherwise update status.
111                if self.status_message.as_deref() == Some(&t!("status.file_saved")) {
112                    self.status_message = Some(t!("status.file_saved_with_actions").to_string());
113                }
114                // else: keep the message set by run_on_save_actions (e.g., missing formatter)
115            }
116            Ok(false) => {
117                // No actions configured, keep original status
118            }
119            Err(e) => {
120                // Action failed, show error but don't fail the save
121                self.status_message = Some(e);
122            }
123        }
124
125        Ok(())
126    }
127
128    /// Revert the active buffer to the last saved version on disk
129    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
130    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
131        let path = match self.active_state().buffer.file_path() {
132            Some(p) => p.to_path_buf(),
133            None => {
134                self.status_message = Some(t!("status.no_file_to_revert").to_string());
135                return Ok(false);
136            }
137        };
138
139        if !path.exists() {
140            self.status_message =
141                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
142            return Ok(false);
143        }
144
145        // Save scroll position (from SplitViewState) and cursor positions before reloading
146        let active_split = self.split_manager.active_split();
147        let (old_top_byte, old_left_column) = self
148            .split_view_states
149            .get(&active_split)
150            .map(|vs| (vs.viewport.top_byte, vs.viewport.left_column))
151            .unwrap_or((0, 0));
152        let old_cursors = self.active_state().cursors.clone();
153
154        // Load the file content fresh from disk
155        let mut new_state = EditorState::from_file_with_languages(
156            &path,
157            self.terminal_width,
158            self.terminal_height,
159            self.config.editor.large_file_threshold_bytes as usize,
160            &self.grammar_registry,
161            &self.config.languages,
162            std::sync::Arc::clone(&self.filesystem),
163        )?;
164
165        // Restore cursor positions (clamped to valid range for new file size)
166        let new_file_size = new_state.buffer.len();
167        let mut restored_cursors = old_cursors;
168        restored_cursors.map(|cursor| {
169            cursor.position = cursor.position.min(new_file_size);
170            // Clear selection since the content may have changed
171            cursor.clear_selection();
172        });
173        new_state.cursors = restored_cursors;
174
175        // Replace the current buffer with the new state
176        let buffer_id = self.active_buffer();
177        if let Some(state) = self.buffers.get_mut(&buffer_id) {
178            *state = new_state;
179            // Note: line_wrap_enabled is now in SplitViewState.viewport
180        }
181
182        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
183        let active_split = self.split_manager.active_split();
184        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
185            view_state.viewport.top_byte = old_top_byte.min(new_file_size);
186            view_state.viewport.left_column = old_left_column;
187        }
188
189        // Clear the undo/redo history for this buffer
190        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
191            *event_log = EventLog::new();
192        }
193
194        // Clear seen_byte_ranges so plugins get notified of all visible lines
195        self.seen_byte_ranges.remove(&buffer_id);
196
197        // Update the file modification time
198        if let Ok(metadata) = self.filesystem.metadata(&path) {
199            if let Some(mtime) = metadata.modified {
200                self.file_mod_times.insert(path.clone(), mtime);
201            }
202        }
203
204        // Notify LSP that the file was changed
205        self.notify_lsp_file_changed(&path);
206
207        self.status_message = Some(t!("status.reverted").to_string());
208        Ok(true)
209    }
210
211    /// Toggle auto-revert mode
212    pub fn toggle_auto_revert(&mut self) {
213        self.auto_revert_enabled = !self.auto_revert_enabled;
214
215        if self.auto_revert_enabled {
216            self.status_message = Some(t!("status.auto_revert_enabled").to_string());
217        } else {
218            self.status_message = Some(t!("status.auto_revert_disabled").to_string());
219        }
220    }
221
222    /// Poll for file changes (called from main loop)
223    ///
224    /// Checks modification times of open files to detect external changes.
225    /// Returns true if any file was changed (requires re-render).
226    pub fn poll_file_changes(&mut self) -> bool {
227        // Skip if auto-revert is disabled
228        if !self.auto_revert_enabled {
229            return false;
230        }
231
232        // Check poll interval
233        let poll_interval =
234            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
235        let elapsed = self.time_source.elapsed_since(self.last_auto_revert_poll);
236        tracing::trace!(
237            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
238            elapsed,
239            poll_interval
240        );
241        if elapsed < poll_interval {
242            return false;
243        }
244        self.last_auto_revert_poll = self.time_source.now();
245
246        // Collect paths of open files that need checking
247        let files_to_check: Vec<PathBuf> = self
248            .buffers
249            .values()
250            .filter_map(|state| state.buffer.file_path().map(PathBuf::from))
251            .collect();
252
253        let mut any_changed = false;
254
255        for path in files_to_check {
256            // Get current mtime
257            let current_mtime = match self.filesystem.metadata(&path) {
258                Ok(meta) => match meta.modified {
259                    Some(mtime) => mtime,
260                    None => continue,
261                },
262                Err(_) => continue, // File might have been deleted
263            };
264
265            // Check if mtime has changed
266            if let Some(&stored_mtime) = self.file_mod_times.get(&path) {
267                if current_mtime != stored_mtime {
268                    // Handle the file change (this includes debouncing)
269                    // Note: file_mod_times is updated by handle_file_changed after successful revert,
270                    // not here, to avoid the race where the revert check sees the already-updated mtime
271                    let path_str = path.display().to_string();
272                    if self.handle_async_file_changed(path_str) {
273                        any_changed = true;
274                    }
275                }
276            } else {
277                // First time seeing this file, record its mtime
278                self.file_mod_times.insert(path, current_mtime);
279            }
280        }
281
282        any_changed
283    }
284
285    /// Poll for file tree changes (called from main loop)
286    ///
287    /// Checks modification times of expanded directories to detect new/deleted files.
288    /// Returns true if any directory was refreshed (requires re-render).
289    pub fn poll_file_tree_changes(&mut self) -> bool {
290        // Check poll interval
291        let poll_interval =
292            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
293        if self.time_source.elapsed_since(self.last_file_tree_poll) < poll_interval {
294            return false;
295        }
296        self.last_file_tree_poll = self.time_source.now();
297
298        // Get file explorer reference
299        let Some(explorer) = &self.file_explorer else {
300            return false;
301        };
302
303        // Collect expanded directories (node_id, path)
304        use crate::view::file_tree::NodeId;
305        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
306            .tree()
307            .all_nodes()
308            .filter(|node| node.is_dir() && node.is_expanded())
309            .map(|node| (node.id, node.entry.path.clone()))
310            .collect();
311
312        // Check mtimes and collect directories that need refresh
313        let mut dirs_to_refresh: Vec<NodeId> = Vec::new();
314
315        for (node_id, path) in expanded_dirs {
316            // Get current mtime
317            let current_mtime = match self.filesystem.metadata(&path) {
318                Ok(meta) => match meta.modified {
319                    Some(mtime) => mtime,
320                    None => continue,
321                },
322                Err(_) => continue, // Directory might have been deleted
323            };
324
325            // Check if mtime has changed
326            if let Some(&stored_mtime) = self.dir_mod_times.get(&path) {
327                if current_mtime != stored_mtime {
328                    // Update stored mtime
329                    self.dir_mod_times.insert(path.clone(), current_mtime);
330                    dirs_to_refresh.push(node_id);
331                    tracing::debug!("Directory changed: {:?}", path);
332                }
333            } else {
334                // First time seeing this directory, record its mtime
335                self.dir_mod_times.insert(path, current_mtime);
336            }
337        }
338
339        // Refresh changed directories
340        if dirs_to_refresh.is_empty() {
341            return false;
342        }
343
344        // Refresh each changed directory
345        if let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) {
346            for node_id in dirs_to_refresh {
347                let tree = explorer.tree_mut();
348                if let Err(e) = runtime.block_on(tree.refresh_node(node_id)) {
349                    tracing::warn!("Failed to refresh directory: {}", e);
350                }
351            }
352        }
353
354        true
355    }
356
357    /// Notify LSP server about a newly opened file
358    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
359    pub(crate) fn notify_lsp_file_opened(
360        &mut self,
361        path: &Path,
362        buffer_id: BufferId,
363        metadata: &mut BufferMetadata,
364    ) {
365        // Early return checks that don't need mutable lsp borrow
366        let Some(language) = detect_language(path, &self.config.languages) else {
367            tracing::debug!("No language detected for file: {}", path.display());
368            return;
369        };
370
371        let Some(uri) = metadata.file_uri().cloned() else {
372            tracing::warn!(
373                "No URI in metadata for file: {} (failed to compute absolute path)",
374                path.display()
375            );
376            return;
377        };
378
379        // Check file size
380        let file_size = self
381            .filesystem
382            .metadata(path)
383            .ok()
384            .map(|m| m.size)
385            .unwrap_or(0);
386        if file_size > self.config.editor.large_file_threshold_bytes {
387            let reason = format!("File too large ({} bytes)", file_size);
388            tracing::warn!(
389                "Skipping LSP for large file: {} ({})",
390                path.display(),
391                reason
392            );
393            metadata.disable_lsp(reason);
394            return;
395        }
396
397        // Get text before borrowing lsp
398        let text = match self
399            .buffers
400            .get(&buffer_id)
401            .and_then(|state| state.buffer.to_string())
402        {
403            Some(t) => t,
404            None => {
405                tracing::debug!("Buffer not fully loaded for LSP notification");
406                return;
407            }
408        };
409
410        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
411        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
412
413        // Get buffer line count for inlay hints
414        let (last_line, last_char) = self
415            .buffers
416            .get(&buffer_id)
417            .map(|state| {
418                let line_count = state.buffer.line_count().unwrap_or(1000);
419                (line_count.saturating_sub(1) as u32, 10000u32)
420            })
421            .unwrap_or((999, 10000));
422
423        // Now borrow lsp and do all LSP operations
424        let Some(lsp) = &mut self.lsp else {
425            tracing::debug!("No LSP manager available");
426            return;
427        };
428
429        tracing::debug!("LSP manager available for file: {}", path.display());
430        tracing::debug!(
431            "Detected language: {} for file: {}",
432            language,
433            path.display()
434        );
435        tracing::debug!("Using URI from metadata: {}", uri.as_str());
436        tracing::debug!("Attempting to spawn LSP client for language: {}", language);
437
438        match lsp.try_spawn(&language) {
439            LspSpawnResult::Spawned => {
440                if let Some(client) = lsp.get_handle_mut(&language) {
441                    // Send didOpen
442                    tracing::info!("Sending didOpen to LSP for: {}", uri.as_str());
443                    if let Err(e) = client.did_open(uri.clone(), text, language.clone()) {
444                        tracing::warn!("Failed to send didOpen to LSP: {}", e);
445                        return;
446                    }
447                    tracing::info!("Successfully sent didOpen to LSP");
448
449                    // Mark this buffer as opened with this server instance
450                    metadata.lsp_opened_with.insert(client.id());
451
452                    // Request pull diagnostics
453                    let request_id = self.next_lsp_request_id;
454                    self.next_lsp_request_id += 1;
455                    if let Err(e) =
456                        client.document_diagnostic(request_id, uri.clone(), previous_result_id)
457                    {
458                        tracing::debug!(
459                            "Failed to request pull diagnostics (server may not support): {}",
460                            e
461                        );
462                    } else {
463                        tracing::info!(
464                            "Requested pull diagnostics for {} (request_id={})",
465                            uri.as_str(),
466                            request_id
467                        );
468                    }
469
470                    // Request inlay hints
471                    if enable_inlay_hints {
472                        let request_id = self.next_lsp_request_id;
473                        self.next_lsp_request_id += 1;
474                        self.pending_inlay_hints_request = Some(request_id);
475
476                        if let Err(e) =
477                            client.inlay_hints(request_id, uri.clone(), 0, 0, last_line, last_char)
478                        {
479                            tracing::debug!(
480                                "Failed to request inlay hints (server may not support): {}",
481                                e
482                            );
483                            self.pending_inlay_hints_request = None;
484                        } else {
485                            tracing::info!(
486                                "Requested inlay hints for {} (request_id={})",
487                                uri.as_str(),
488                                request_id
489                            );
490                        }
491                    }
492                }
493            }
494            LspSpawnResult::NotAutoStart => {
495                tracing::debug!(
496                    "LSP for {} not auto-starting (auto_start=false). Use command palette to start manually.",
497                    language
498                );
499            }
500            LspSpawnResult::Failed => {
501                tracing::warn!("Failed to spawn LSP client for language: {}", language);
502            }
503        }
504    }
505
506    /// Record a file's modification time (called when opening files)
507    /// This is used by the polling-based auto-revert to detect external changes
508    pub(crate) fn watch_file(&mut self, path: &Path) {
509        // Record current modification time for polling
510        if let Ok(metadata) = self.filesystem.metadata(path) {
511            if let Some(mtime) = metadata.modified {
512                self.file_mod_times.insert(path.to_path_buf(), mtime);
513            }
514        }
515    }
516
517    /// Notify LSP that a file's contents changed (e.g., after revert)
518    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
519        use crate::services::lsp::manager::LspSpawnResult;
520
521        let Ok(uri) = url::Url::from_file_path(path) else {
522            return;
523        };
524        let Ok(lsp_uri) = uri.as_str().parse::<lsp_types::Uri>() else {
525            return;
526        };
527        let Some(language) = detect_language(path, &self.config.languages) else {
528            return;
529        };
530
531        // Find the buffer ID for this path
532        let Some((buffer_id, content)) = self
533            .buffers
534            .iter()
535            .find(|(_, s)| s.buffer.file_path() == Some(path))
536            .and_then(|(id, state)| state.buffer.to_string().map(|t| (*id, t)))
537        else {
538            return;
539        };
540
541        // Check if we can spawn LSP (respects auto_start setting)
542        let spawn_result = {
543            let Some(lsp) = self.lsp.as_mut() else {
544                return;
545            };
546            lsp.try_spawn(&language)
547        };
548
549        // Only proceed if spawned successfully (or already running)
550        if spawn_result != LspSpawnResult::Spawned {
551            return;
552        }
553
554        // Get handle ID (handle should exist now since try_spawn succeeded)
555        let handle_id = {
556            let Some(lsp) = self.lsp.as_mut() else {
557                return;
558            };
559            let Some(handle) = lsp.get_handle_mut(&language) else {
560                return;
561            };
562            handle.id()
563        };
564
565        // Check if didOpen needs to be sent first
566        let needs_open = {
567            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
568                return;
569            };
570            !metadata.lsp_opened_with.contains(&handle_id)
571        };
572
573        if needs_open {
574            // Send didOpen first
575            if let Some(lsp) = self.lsp.as_mut() {
576                if let Some(handle) = lsp.get_handle_mut(&language) {
577                    if let Err(e) =
578                        handle.did_open(lsp_uri.clone(), content.clone(), language.clone())
579                    {
580                        tracing::warn!("Failed to send didOpen before didChange: {}", e);
581                        return;
582                    }
583                    tracing::debug!(
584                        "Sent didOpen for {} to LSP handle {} before file change notification",
585                        lsp_uri.as_str(),
586                        handle_id
587                    );
588                }
589            }
590
591            // Mark as opened
592            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
593                metadata.lsp_opened_with.insert(handle_id);
594            }
595        }
596
597        // Use full document sync - send the entire new content
598        if let Some(lsp) = &mut self.lsp {
599            if let Some(client) = lsp.get_handle_mut(&language) {
600                let content_change = TextDocumentContentChangeEvent {
601                    range: None, // None means full document replacement
602                    range_length: None,
603                    text: content,
604                };
605                if let Err(e) = client.did_change(lsp_uri, vec![content_change]) {
606                    tracing::warn!("Failed to notify LSP of file change: {}", e);
607                }
608            }
609        }
610    }
611
612    /// Revert a specific buffer by ID without affecting the active viewport.
613    ///
614    /// This is used for auto-reverting background buffers that aren't currently
615    /// visible in the active split. It reloads the buffer content and updates
616    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
617    pub(crate) fn revert_buffer_by_id(
618        &mut self,
619        buffer_id: BufferId,
620        path: &Path,
621    ) -> anyhow::Result<()> {
622        // Load the file content fresh from disk
623        let new_state = EditorState::from_file_with_languages(
624            path,
625            self.terminal_width,
626            self.terminal_height,
627            self.config.editor.large_file_threshold_bytes as usize,
628            &self.grammar_registry,
629            &self.config.languages,
630            std::sync::Arc::clone(&self.filesystem),
631        )?;
632
633        // Get the new file size for clamping
634        let new_file_size = new_state.buffer.len();
635
636        // Get old cursors before replacing the buffer
637        let old_cursors = self
638            .buffers
639            .get(&buffer_id)
640            .map(|s| s.cursors.clone())
641            .unwrap_or_default();
642
643        // Replace the buffer content
644        if let Some(state) = self.buffers.get_mut(&buffer_id) {
645            *state = new_state;
646
647            // Restore cursor positions (clamped to valid range for new file size)
648            let mut restored_cursors = old_cursors;
649            restored_cursors.map(|cursor| {
650                cursor.position = cursor.position.min(new_file_size);
651                cursor.clear_selection();
652            });
653            state.cursors = restored_cursors;
654        }
655
656        // Clear the undo/redo history for this buffer
657        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
658            *event_log = EventLog::new();
659        }
660
661        // Clear seen_byte_ranges so plugins get notified of all visible lines
662        self.seen_byte_ranges.remove(&buffer_id);
663
664        // Update the file modification time
665        if let Ok(metadata) = self.filesystem.metadata(path) {
666            if let Some(mtime) = metadata.modified {
667                self.file_mod_times.insert(path.to_path_buf(), mtime);
668            }
669        }
670
671        // Notify LSP that the file was changed
672        self.notify_lsp_file_changed(path);
673
674        Ok(())
675    }
676
677    /// Handle a file change notification (from file watcher)
678    pub fn handle_file_changed(&mut self, changed_path: &str) {
679        let path = PathBuf::from(changed_path);
680
681        // Find buffers that have this file open
682        let buffer_ids: Vec<BufferId> = self
683            .buffers
684            .iter()
685            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
686            .map(|(id, _)| *id)
687            .collect();
688
689        if buffer_ids.is_empty() {
690            return;
691        }
692
693        for buffer_id in buffer_ids {
694            // Skip terminal buffers - they manage their own content via PTY streaming
695            // and should not be auto-reverted (which would reset editing_disabled and line_numbers)
696            if self.terminal_buffers.contains_key(&buffer_id) {
697                continue;
698            }
699
700            let state = match self.buffers.get(&buffer_id) {
701                Some(s) => s,
702                None => continue,
703            };
704
705            // Check if the file actually changed (compare mod times)
706            // We use optimistic concurrency: check mtime, and if we decide to revert,
707            // re-check to handle the race where a save completed between our checks.
708            let current_mtime = match self
709                .filesystem
710                .metadata(&path)
711                .ok()
712                .and_then(|m| m.modified)
713            {
714                Some(mtime) => mtime,
715                None => continue, // Can't read file, skip
716            };
717
718            let dominated_by_stored = self
719                .file_mod_times
720                .get(&path)
721                .map(|stored| current_mtime <= *stored)
722                .unwrap_or(false);
723
724            if dominated_by_stored {
725                continue;
726            }
727
728            // If buffer has local modifications, show a warning (don't auto-revert)
729            if state.buffer.is_modified() {
730                self.status_message = Some(format!(
731                    "File {} changed on disk (buffer has unsaved changes)",
732                    path.display()
733                ));
734                continue;
735            }
736
737            // Auto-revert if enabled and buffer is not modified
738            if self.auto_revert_enabled {
739                // Optimistic concurrency: re-check mtime before reverting.
740                // A save may have completed between our first check and now,
741                // updating file_mod_times. If so, skip the revert.
742                let still_needs_revert = self
743                    .file_mod_times
744                    .get(&path)
745                    .map(|stored| current_mtime > *stored)
746                    .unwrap_or(true);
747
748                if !still_needs_revert {
749                    continue;
750                }
751
752                // Check if this buffer is currently displayed in the active split
753                let is_active_buffer = buffer_id == self.active_buffer();
754
755                if is_active_buffer {
756                    // Use revert_file() which preserves viewport for active buffer
757                    if let Err(e) = self.revert_file() {
758                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
759                    } else {
760                        tracing::info!("Auto-reverted file: {:?}", path);
761                    }
762                } else {
763                    // Use revert_buffer_by_id() which doesn't touch any viewport
764                    // This prevents corrupting the active split's viewport state
765                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
766                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
767                    } else {
768                        tracing::info!("Auto-reverted file: {:?}", path);
769                    }
770                }
771
772                // Update the modification time tracking for this file
773                self.watch_file(&path);
774            }
775        }
776    }
777
778    /// Check if saving would overwrite changes made by another process
779    /// Returns Some(current_mtime) if there's a conflict, None otherwise
780    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
781        let path = self.active_state().buffer.file_path()?;
782
783        // Get current file modification time
784        let current_mtime = match self.filesystem.metadata(path).ok().and_then(|m| m.modified) {
785            Some(mtime) => mtime,
786            None => return None, // File doesn't exist or can't read metadata
787        };
788
789        // Compare with our recorded modification time
790        match self.file_mod_times.get(path) {
791            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
792                // File was modified externally since we last loaded/saved it
793                Some(current_mtime)
794            }
795            _ => None,
796        }
797    }
798}