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    /// Revert the active buffer to the last saved version on disk
211    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
212    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
213        let path = match self.active_state().buffer.file_path() {
214            Some(p) => p.to_path_buf(),
215            None => {
216                self.status_message = Some(t!("status.no_file_to_revert").to_string());
217                return Ok(false);
218            }
219        };
220
221        if !path.exists() {
222            self.status_message =
223                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
224            return Ok(false);
225        }
226
227        // Save scroll position (from SplitViewState) and cursor positions before reloading
228        let active_split = self.split_manager.active_split();
229        let (old_top_byte, old_left_column) = self
230            .split_view_states
231            .get(&active_split)
232            .map(|vs| (vs.viewport.top_byte, vs.viewport.left_column))
233            .unwrap_or((0, 0));
234        let old_cursors = self.active_cursors().clone();
235
236        // Preserve user settings before reloading
237        let old_buffer_settings = self.active_state().buffer_settings.clone();
238        let old_editing_disabled = self.active_state().editing_disabled;
239
240        // Load the file content fresh from disk
241        let mut new_state = EditorState::from_file_with_languages(
242            &path,
243            self.terminal_width,
244            self.terminal_height,
245            self.config.editor.large_file_threshold_bytes as usize,
246            &self.grammar_registry,
247            &self.config.languages,
248            std::sync::Arc::clone(&self.filesystem),
249        )?;
250
251        // Restore cursor positions (clamped to valid range for new file size)
252        let new_file_size = new_state.buffer.len();
253        let mut restored_cursors = old_cursors;
254        restored_cursors.map(|cursor| {
255            cursor.position = cursor.position.min(new_file_size);
256            // Clear selection since the content may have changed
257            cursor.clear_selection();
258        });
259        // Restore user settings (tab size, indentation, etc.)
260        new_state.buffer_settings = old_buffer_settings;
261        new_state.editing_disabled = old_editing_disabled;
262        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
263
264        // Replace the current buffer with the new state
265        let buffer_id = self.active_buffer();
266        if let Some(state) = self.buffers.get_mut(&buffer_id) {
267            *state = new_state;
268            // Note: line_wrap_enabled is now in SplitViewState.viewport
269        }
270
271        // Restore cursor positions in SplitViewState (clamped to valid range for new file size)
272        let active_split = self.split_manager.active_split();
273        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
274            view_state.cursors = restored_cursors;
275        }
276
277        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
278        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
279            view_state.viewport.top_byte = old_top_byte.min(new_file_size);
280            view_state.viewport.left_column = old_left_column;
281        }
282
283        // Clear the undo/redo history for this buffer
284        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
285            *event_log = EventLog::new();
286        }
287
288        // Clear seen_byte_ranges so plugins get notified of all visible lines
289        self.seen_byte_ranges.remove(&buffer_id);
290
291        // Update the file modification time
292        if let Ok(metadata) = self.filesystem.metadata(&path) {
293            if let Some(mtime) = metadata.modified {
294                self.file_mod_times.insert(path.clone(), mtime);
295            }
296        }
297
298        // Notify LSP that the file was changed
299        self.notify_lsp_file_changed(&path);
300
301        self.status_message = Some(t!("status.reverted").to_string());
302        Ok(true)
303    }
304
305    /// Toggle auto-revert mode
306    pub fn toggle_auto_revert(&mut self) {
307        self.auto_revert_enabled = !self.auto_revert_enabled;
308
309        if self.auto_revert_enabled {
310            self.status_message = Some(t!("status.auto_revert_enabled").to_string());
311        } else {
312            self.status_message = Some(t!("status.auto_revert_disabled").to_string());
313        }
314    }
315
316    /// Poll for file changes (called from main loop)
317    ///
318    /// Checks modification times of open files to detect external changes.
319    /// Returns true if any file was changed (requires re-render).
320    pub fn poll_file_changes(&mut self) -> bool {
321        // Skip if auto-revert is disabled
322        if !self.auto_revert_enabled {
323            return false;
324        }
325
326        // Check poll interval
327        let poll_interval =
328            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
329        let elapsed = self.time_source.elapsed_since(self.last_auto_revert_poll);
330        tracing::trace!(
331            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
332            elapsed,
333            poll_interval
334        );
335        if elapsed < poll_interval {
336            return false;
337        }
338        self.last_auto_revert_poll = self.time_source.now();
339
340        // Collect paths of open files that need checking
341        let files_to_check: Vec<PathBuf> = self
342            .buffers
343            .values()
344            .filter_map(|state| state.buffer.file_path().map(PathBuf::from))
345            .collect();
346
347        let mut any_changed = false;
348
349        for path in files_to_check {
350            // Get current mtime
351            let current_mtime = match self.filesystem.metadata(&path) {
352                Ok(meta) => match meta.modified {
353                    Some(mtime) => mtime,
354                    None => continue,
355                },
356                Err(_) => continue, // File might have been deleted
357            };
358
359            // Check if mtime has changed
360            if let Some(&stored_mtime) = self.file_mod_times.get(&path) {
361                if current_mtime != stored_mtime {
362                    // Handle the file change (this includes debouncing)
363                    // Note: file_mod_times is updated by handle_file_changed after successful revert,
364                    // not here, to avoid the race where the revert check sees the already-updated mtime
365                    let path_str = path.display().to_string();
366                    if self.handle_async_file_changed(path_str) {
367                        any_changed = true;
368                    }
369                }
370            } else {
371                // First time seeing this file, record its mtime
372                self.file_mod_times.insert(path, current_mtime);
373            }
374        }
375
376        any_changed
377    }
378
379    /// Poll for file tree changes (called from main loop)
380    ///
381    /// Checks modification times of expanded directories to detect new/deleted files.
382    /// Returns true if any directory was refreshed (requires re-render).
383    pub fn poll_file_tree_changes(&mut self) -> bool {
384        // Check poll interval
385        let poll_interval =
386            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
387        if self.time_source.elapsed_since(self.last_file_tree_poll) < poll_interval {
388            return false;
389        }
390        self.last_file_tree_poll = self.time_source.now();
391
392        // Get file explorer reference
393        let Some(explorer) = &self.file_explorer else {
394            return false;
395        };
396
397        // Collect expanded directories (node_id, path)
398        use crate::view::file_tree::NodeId;
399        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
400            .tree()
401            .all_nodes()
402            .filter(|node| node.is_dir() && node.is_expanded())
403            .map(|node| (node.id, node.entry.path.clone()))
404            .collect();
405
406        // Check mtimes and collect directories that need refresh
407        let mut dirs_to_refresh: Vec<NodeId> = Vec::new();
408
409        for (node_id, path) in expanded_dirs {
410            // Get current mtime
411            let current_mtime = match self.filesystem.metadata(&path) {
412                Ok(meta) => match meta.modified {
413                    Some(mtime) => mtime,
414                    None => continue,
415                },
416                Err(_) => continue, // Directory might have been deleted
417            };
418
419            // Check if mtime has changed
420            if let Some(&stored_mtime) = self.dir_mod_times.get(&path) {
421                if current_mtime != stored_mtime {
422                    // Update stored mtime
423                    self.dir_mod_times.insert(path.clone(), current_mtime);
424                    dirs_to_refresh.push(node_id);
425                    tracing::debug!("Directory changed: {:?}", path);
426                }
427            } else {
428                // First time seeing this directory, record its mtime
429                self.dir_mod_times.insert(path, current_mtime);
430            }
431        }
432
433        // Refresh changed directories
434        if dirs_to_refresh.is_empty() {
435            return false;
436        }
437
438        // Refresh each changed directory
439        if let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) {
440            for node_id in dirs_to_refresh {
441                let tree = explorer.tree_mut();
442                if let Err(e) = runtime.block_on(tree.refresh_node(node_id)) {
443                    tracing::warn!("Failed to refresh directory: {}", e);
444                }
445            }
446        }
447
448        true
449    }
450
451    /// Notify LSP server about a newly opened file
452    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
453    pub(crate) fn notify_lsp_file_opened(
454        &mut self,
455        path: &Path,
456        buffer_id: BufferId,
457        metadata: &mut BufferMetadata,
458    ) {
459        // Get language from buffer state
460        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
461            tracing::debug!("No buffer state for file: {}", path.display());
462            return;
463        };
464
465        let Some(uri) = metadata.file_uri().cloned() else {
466            tracing::warn!(
467                "No URI in metadata for file: {} (failed to compute absolute path)",
468                path.display()
469            );
470            return;
471        };
472
473        // Check file size
474        let file_size = self
475            .filesystem
476            .metadata(path)
477            .ok()
478            .map(|m| m.size)
479            .unwrap_or(0);
480        if file_size > self.config.editor.large_file_threshold_bytes {
481            let reason = format!("File too large ({} bytes)", file_size);
482            tracing::warn!(
483                "Skipping LSP for large file: {} ({})",
484                path.display(),
485                reason
486            );
487            metadata.disable_lsp(reason);
488            return;
489        }
490
491        // Get text before borrowing lsp
492        let text = match self
493            .buffers
494            .get(&buffer_id)
495            .and_then(|state| state.buffer.to_string())
496        {
497            Some(t) => t,
498            None => {
499                tracing::debug!("Buffer not fully loaded for LSP notification");
500                return;
501            }
502        };
503
504        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
505        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
506
507        // Get buffer line count for inlay hints
508        let (last_line, last_char) = self
509            .buffers
510            .get(&buffer_id)
511            .map(|state| {
512                let line_count = state.buffer.line_count().unwrap_or(1000);
513                (line_count.saturating_sub(1) as u32, 10000u32)
514            })
515            .unwrap_or((999, 10000));
516
517        // Now borrow lsp and do all LSP operations
518        let Some(lsp) = &mut self.lsp else {
519            tracing::debug!("No LSP manager available");
520            return;
521        };
522
523        tracing::debug!("LSP manager available for file: {}", path.display());
524        tracing::debug!(
525            "Detected language: {} for file: {}",
526            language,
527            path.display()
528        );
529        tracing::debug!("Using URI from metadata: {}", uri.as_str());
530        tracing::debug!("Attempting to spawn LSP client for language: {}", language);
531
532        match lsp.try_spawn(&language) {
533            LspSpawnResult::Spawned => {
534                if let Some(client) = lsp.get_handle_mut(&language) {
535                    // Send didOpen
536                    tracing::info!("Sending didOpen to LSP for: {}", uri.as_str());
537                    if let Err(e) = client.did_open(uri.clone(), text, language.clone()) {
538                        tracing::warn!("Failed to send didOpen to LSP: {}", e);
539                        return;
540                    }
541                    tracing::info!("Successfully sent didOpen to LSP");
542
543                    // Mark this buffer as opened with this server instance
544                    metadata.lsp_opened_with.insert(client.id());
545
546                    // Request pull diagnostics
547                    let request_id = self.next_lsp_request_id;
548                    self.next_lsp_request_id += 1;
549                    if let Err(e) =
550                        client.document_diagnostic(request_id, uri.clone(), previous_result_id)
551                    {
552                        tracing::debug!(
553                            "Failed to request pull diagnostics (server may not support): {}",
554                            e
555                        );
556                    } else {
557                        tracing::info!(
558                            "Requested pull diagnostics for {} (request_id={})",
559                            uri.as_str(),
560                            request_id
561                        );
562                    }
563
564                    // Request inlay hints
565                    if enable_inlay_hints {
566                        let request_id = self.next_lsp_request_id;
567                        self.next_lsp_request_id += 1;
568                        self.pending_inlay_hints_request = Some(request_id);
569
570                        if let Err(e) =
571                            client.inlay_hints(request_id, uri.clone(), 0, 0, last_line, last_char)
572                        {
573                            tracing::debug!(
574                                "Failed to request inlay hints (server may not support): {}",
575                                e
576                            );
577                            self.pending_inlay_hints_request = None;
578                        } else {
579                            tracing::info!(
580                                "Requested inlay hints for {} (request_id={})",
581                                uri.as_str(),
582                                request_id
583                            );
584                        }
585                    }
586
587                    // Schedule folding range refresh
588                    self.schedule_folding_ranges_refresh(buffer_id);
589                }
590            }
591            LspSpawnResult::NotAutoStart => {
592                tracing::debug!(
593                    "LSP for {} not auto-starting (auto_start=false). Use command palette to start manually.",
594                    language
595                );
596            }
597            LspSpawnResult::NotConfigured => {
598                tracing::debug!("No LSP server configured for language: {}", language);
599            }
600            LspSpawnResult::Failed => {
601                tracing::warn!("Failed to spawn LSP client for language: {}", language);
602            }
603        }
604    }
605
606    /// Record a file's modification time (called when opening files)
607    /// This is used by the polling-based auto-revert to detect external changes
608    pub(crate) fn watch_file(&mut self, path: &Path) {
609        // Record current modification time for polling
610        if let Ok(metadata) = self.filesystem.metadata(path) {
611            if let Some(mtime) = metadata.modified {
612                self.file_mod_times.insert(path.to_path_buf(), mtime);
613            }
614        }
615    }
616
617    /// Notify LSP that a file's contents changed (e.g., after revert)
618    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
619        use crate::services::lsp::manager::LspSpawnResult;
620
621        let Some(lsp_uri) = super::types::file_path_to_lsp_uri(path) else {
622            return;
623        };
624
625        // Find the buffer ID, content, and language for this path
626        let Some((buffer_id, content, language)) = self
627            .buffers
628            .iter()
629            .find(|(_, s)| s.buffer.file_path() == Some(path))
630            .and_then(|(id, state)| {
631                state
632                    .buffer
633                    .to_string()
634                    .map(|t| (*id, t, state.language.clone()))
635            })
636        else {
637            return;
638        };
639
640        // Check if we can spawn LSP (respects auto_start setting)
641        let spawn_result = {
642            let Some(lsp) = self.lsp.as_mut() else {
643                return;
644            };
645            lsp.try_spawn(&language)
646        };
647
648        // Only proceed if spawned successfully (or already running)
649        if spawn_result != LspSpawnResult::Spawned {
650            return;
651        }
652
653        // Get handle ID (handle should exist now since try_spawn succeeded)
654        let handle_id = {
655            let Some(lsp) = self.lsp.as_mut() else {
656                return;
657            };
658            let Some(handle) = lsp.get_handle_mut(&language) else {
659                return;
660            };
661            handle.id()
662        };
663
664        // Check if didOpen needs to be sent first
665        let needs_open = {
666            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
667                return;
668            };
669            !metadata.lsp_opened_with.contains(&handle_id)
670        };
671
672        if needs_open {
673            // Send didOpen first
674            if let Some(lsp) = self.lsp.as_mut() {
675                if let Some(handle) = lsp.get_handle_mut(&language) {
676                    if let Err(e) =
677                        handle.did_open(lsp_uri.clone(), content.clone(), language.clone())
678                    {
679                        tracing::warn!("Failed to send didOpen before didChange: {}", e);
680                        return;
681                    }
682                    tracing::debug!(
683                        "Sent didOpen for {} to LSP handle {} before file change notification",
684                        lsp_uri.as_str(),
685                        handle_id
686                    );
687                }
688            }
689
690            // Mark as opened
691            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
692                metadata.lsp_opened_with.insert(handle_id);
693            }
694        }
695
696        // Use full document sync - send the entire new content
697        if let Some(lsp) = &mut self.lsp {
698            if let Some(client) = lsp.get_handle_mut(&language) {
699                let content_change = TextDocumentContentChangeEvent {
700                    range: None, // None means full document replacement
701                    range_length: None,
702                    text: content,
703                };
704                if let Err(e) = client.did_change(lsp_uri, vec![content_change]) {
705                    tracing::warn!("Failed to notify LSP of file change: {}", e);
706                }
707            }
708        }
709    }
710
711    /// Revert a specific buffer by ID without affecting the active viewport.
712    ///
713    /// This is used for auto-reverting background buffers that aren't currently
714    /// visible in the active split. It reloads the buffer content and updates
715    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
716    pub(crate) fn revert_buffer_by_id(
717        &mut self,
718        buffer_id: BufferId,
719        path: &Path,
720    ) -> anyhow::Result<()> {
721        // Preserve user settings before reloading
722        // TODO: Consider moving line numbers to SplitViewState (per-view setting)
723        // Get cursors from split view states for this buffer (find any split showing it)
724        let old_cursors = self
725            .split_view_states
726            .values()
727            .find_map(|vs| {
728                if vs.keyed_states.contains_key(&buffer_id) {
729                    vs.keyed_states.get(&buffer_id).map(|bs| bs.cursors.clone())
730                } else {
731                    None
732                }
733            })
734            .unwrap_or_default();
735        let (old_buffer_settings, old_editing_disabled) = self
736            .buffers
737            .get(&buffer_id)
738            .map(|s| (s.buffer_settings.clone(), s.editing_disabled))
739            .unwrap_or_default();
740
741        // Load the file content fresh from disk
742        let mut new_state = EditorState::from_file_with_languages(
743            path,
744            self.terminal_width,
745            self.terminal_height,
746            self.config.editor.large_file_threshold_bytes as usize,
747            &self.grammar_registry,
748            &self.config.languages,
749            std::sync::Arc::clone(&self.filesystem),
750        )?;
751
752        // Get the new file size for clamping
753        let new_file_size = new_state.buffer.len();
754
755        // Restore cursor positions (clamped to valid range for new file size)
756        let mut restored_cursors = old_cursors;
757        restored_cursors.map(|cursor| {
758            cursor.position = cursor.position.min(new_file_size);
759            cursor.clear_selection();
760        });
761        // Restore user settings (tab size, indentation, etc.)
762        new_state.buffer_settings = old_buffer_settings;
763        new_state.editing_disabled = old_editing_disabled;
764        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
765
766        // Replace the buffer content
767        if let Some(state) = self.buffers.get_mut(&buffer_id) {
768            *state = new_state;
769        }
770
771        // Restore cursors in any split view states that have this buffer
772        for vs in self.split_view_states.values_mut() {
773            if let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) {
774                buf_state.cursors = restored_cursors.clone();
775            }
776        }
777
778        // Clear the undo/redo history for this buffer
779        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
780            *event_log = EventLog::new();
781        }
782
783        // Clear seen_byte_ranges so plugins get notified of all visible lines
784        self.seen_byte_ranges.remove(&buffer_id);
785
786        // Update the file modification time
787        if let Ok(metadata) = self.filesystem.metadata(path) {
788            if let Some(mtime) = metadata.modified {
789                self.file_mod_times.insert(path.to_path_buf(), mtime);
790            }
791        }
792
793        // Notify LSP that the file was changed
794        self.notify_lsp_file_changed(path);
795
796        Ok(())
797    }
798
799    /// Handle a file change notification (from file watcher)
800    pub fn handle_file_changed(&mut self, changed_path: &str) {
801        let path = PathBuf::from(changed_path);
802
803        // Find buffers that have this file open
804        let buffer_ids: Vec<BufferId> = self
805            .buffers
806            .iter()
807            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
808            .map(|(id, _)| *id)
809            .collect();
810
811        if buffer_ids.is_empty() {
812            return;
813        }
814
815        for buffer_id in buffer_ids {
816            // Skip terminal buffers - they manage their own content via PTY streaming
817            // and should not be auto-reverted (which would reset editing_disabled and line_numbers)
818            if self.terminal_buffers.contains_key(&buffer_id) {
819                continue;
820            }
821
822            let state = match self.buffers.get(&buffer_id) {
823                Some(s) => s,
824                None => continue,
825            };
826
827            // Check if the file actually changed (compare mod times)
828            // We use optimistic concurrency: check mtime, and if we decide to revert,
829            // re-check to handle the race where a save completed between our checks.
830            let current_mtime = match self
831                .filesystem
832                .metadata(&path)
833                .ok()
834                .and_then(|m| m.modified)
835            {
836                Some(mtime) => mtime,
837                None => continue, // Can't read file, skip
838            };
839
840            let dominated_by_stored = self
841                .file_mod_times
842                .get(&path)
843                .map(|stored| current_mtime <= *stored)
844                .unwrap_or(false);
845
846            if dominated_by_stored {
847                continue;
848            }
849
850            // If buffer has local modifications, show a warning (don't auto-revert)
851            if state.buffer.is_modified() {
852                self.status_message = Some(format!(
853                    "File {} changed on disk (buffer has unsaved changes)",
854                    path.display()
855                ));
856                continue;
857            }
858
859            // Auto-revert if enabled and buffer is not modified
860            if self.auto_revert_enabled {
861                // Optimistic concurrency: re-check mtime before reverting.
862                // A save may have completed between our first check and now,
863                // updating file_mod_times. If so, skip the revert.
864                let still_needs_revert = self
865                    .file_mod_times
866                    .get(&path)
867                    .map(|stored| current_mtime > *stored)
868                    .unwrap_or(true);
869
870                if !still_needs_revert {
871                    continue;
872                }
873
874                // Check if this buffer is currently displayed in the active split
875                let is_active_buffer = buffer_id == self.active_buffer();
876
877                if is_active_buffer {
878                    // Use revert_file() which preserves viewport for active buffer
879                    if let Err(e) = self.revert_file() {
880                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
881                    } else {
882                        tracing::info!("Auto-reverted file: {:?}", path);
883                    }
884                } else {
885                    // Use revert_buffer_by_id() which doesn't touch any viewport
886                    // This prevents corrupting the active split's viewport state
887                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
888                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
889                    } else {
890                        tracing::info!("Auto-reverted file: {:?}", path);
891                    }
892                }
893
894                // Update the modification time tracking for this file
895                self.watch_file(&path);
896            }
897        }
898    }
899
900    /// Check if saving would overwrite changes made by another process
901    /// Returns Some(current_mtime) if there's a conflict, None otherwise
902    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
903        let path = self.active_state().buffer.file_path()?;
904
905        // Get current file modification time
906        let current_mtime = self
907            .filesystem
908            .metadata(path)
909            .ok()
910            .and_then(|m| m.modified)?;
911
912        // Compare with our recorded modification time
913        match self.file_mod_times.get(path) {
914            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
915                // File was modified externally since we last loaded/saved it
916                Some(current_mtime)
917            }
918            _ => None,
919        }
920    }
921}