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