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