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::model::filesystem::FileSystem;
13use crate::view::file_tree::FileTreeView;
14use crate::view::prompt::PromptType;
15use std::path::{Path, PathBuf};
16
17use lsp_types::TextDocumentContentChangeEvent;
18use rust_i18n::t;
19
20use crate::model::event::{BufferId, EventLog};
21use crate::services::lsp::manager::LspSpawnResult;
22use crate::state::EditorState;
23
24use super::{BufferMetadata, Editor};
25
26impl Editor {
27    /// Save the active buffer
28    pub fn save(&mut self) -> anyhow::Result<()> {
29        // Fail fast if remote connection is down
30        if !self.authority.filesystem.is_remote_connected() {
31            anyhow::bail!(
32                "Cannot save: remote connection lost ({})",
33                self.authority
34                    .filesystem
35                    .remote_connection_info()
36                    .unwrap_or("unknown host")
37            );
38        }
39
40        let path = self
41            .active_state()
42            .buffer
43            .file_path()
44            .map(|p| p.to_path_buf());
45
46        match self.active_state_mut().buffer.save() {
47            Ok(()) => self.finalize_save(path),
48            Err(e) => {
49                if let Some(sudo_info) = e.downcast_ref::<SudoSaveRequired>() {
50                    let info = sudo_info.clone();
51                    self.start_prompt(
52                        t!("prompt.sudo_save_confirm").to_string(),
53                        PromptType::ConfirmSudoSave { info },
54                    );
55                    Ok(())
56                } else if let Some(path) = path {
57                    // Check if failure is due to non-existent parent directory
58                    let is_not_found = e
59                        .downcast_ref::<std::io::Error>()
60                        .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound);
61                    if is_not_found {
62                        if let Some(parent) = path.parent() {
63                            if !self.authority.filesystem.exists(parent) {
64                                let dir_name = parent
65                                    .strip_prefix(self.working_dir())
66                                    .unwrap_or(parent)
67                                    .display()
68                                    .to_string();
69                                self.start_prompt(
70                                    t!("buffer.create_directory_confirm", name = &dir_name)
71                                        .to_string(),
72                                    PromptType::ConfirmCreateDirectory { path },
73                                );
74                                return Ok(());
75                            }
76                        }
77                    }
78                    Err(e)
79                } else {
80                    Err(e)
81                }
82            }
83        }
84    }
85
86    /// Internal helper to finalize save state (mark as saved, notify LSP, etc.)
87    pub(crate) fn finalize_save(&mut self, path: Option<PathBuf>) -> anyhow::Result<()> {
88        let buffer_id = self.active_buffer();
89        self.finalize_save_buffer(buffer_id, path, false)
90    }
91
92    /// Internal helper to finalize save state for a specific buffer
93    pub(crate) fn finalize_save_buffer(
94        &mut self,
95        buffer_id: BufferId,
96        path: Option<PathBuf>,
97        silent: bool,
98    ) -> anyhow::Result<()> {
99        // Auto-detect language if it's currently "text" and we have a path
100        if let Some(ref p) = path {
101            if let Some(state) = self
102                .windows
103                .get_mut(&self.active_window)
104                .map(|w| &mut w.buffers)
105                .expect("active window present")
106                .get_mut(&buffer_id)
107            {
108                if state.language == "text" {
109                    let first_line = state.buffer.first_line_lossy();
110                    let detected =
111                        crate::primitives::detected_language::DetectedLanguage::from_path(
112                            p,
113                            first_line.as_deref(),
114                            &self.grammar_registry,
115                            &self.config.languages,
116                        );
117                    state.apply_language(detected);
118                }
119            }
120        }
121
122        if !silent {
123            self.active_window_mut().status_message = Some(t!("status.file_saved").to_string());
124        }
125
126        // Mark the event log position as saved (for undo modified tracking)
127        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
128            event_log.mark_saved();
129        }
130
131        // Update file modification time after save
132        if let Some(ref p) = path {
133            if let Ok(metadata) = self.authority.filesystem.metadata(p) {
134                if let Some(mtime) = metadata.modified {
135                    self.file_mod_times_mut().insert(p.clone(), mtime);
136                }
137            }
138        }
139
140        // Reload .gitignore in the file explorer when the user saves one.
141        // Otherwise the tree keeps filtering by the old rules until restart.
142        if let Some(ref p) = path {
143            if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
144                if let Some(parent) = p.parent() {
145                    let parent = parent.to_path_buf();
146                    let fs = self.authority.filesystem.clone();
147                    if let Some(explorer) = self.file_explorer_mut().as_mut() {
148                        load_gitignore_via_fs(fs.as_ref(), explorer, &parent);
149                    }
150                }
151            }
152        }
153
154        // Notify LSP of save
155        self.active_window_mut().notify_lsp_save_buffer(buffer_id);
156
157        // Delete recovery file (buffer is now saved)
158        if let Err(e) = self.delete_buffer_recovery(buffer_id) {
159            tracing::warn!("Failed to delete recovery file: {}", e);
160        }
161
162        // Emit control event
163        if let Some(ref p) = path {
164            self.emit_event(
165                crate::model::control_event::events::FILE_SAVED.name,
166                serde_json::json!({
167                    "path": p.display().to_string()
168                }),
169            );
170        }
171
172        // Fire AfterFileSave hook for plugins
173        if let Some(ref p) = path {
174            self.plugin_manager.read().unwrap().run_hook(
175                "after_file_save",
176                crate::services::plugins::hooks::HookArgs::AfterFileSave {
177                    buffer_id,
178                    path: p.clone(),
179                },
180            );
181        }
182
183        // Run on-save actions (formatters, linters, etc.)
184        // Note: run_on_save_actions also assumes active_buffer internally.
185        // We might need to refactor it too if we want auto-save to trigger formatters.
186        // For now, let's just do it for active buffer or skip for silent auto-saves.
187
188        if !silent {
189            match self.run_on_save_actions() {
190                Ok(true) => {
191                    // Actions ran successfully - if status_message was set by run_on_save_actions
192                    // (e.g., for missing optional formatters), keep it. Otherwise update status.
193                    if self.active_window_mut().status_message.as_deref()
194                        == Some(&t!("status.file_saved"))
195                    {
196                        self.active_window_mut().status_message =
197                            Some(t!("status.file_saved_with_actions").to_string());
198                    }
199                    // else: keep the message set by run_on_save_actions (e.g., missing formatter)
200                }
201                Ok(false) => {
202                    // No actions configured, keep original status
203                }
204                Err(e) => {
205                    // Action failed, show error but don't fail the save
206                    self.active_window_mut().status_message = Some(e);
207                }
208            }
209        }
210
211        Ok(())
212    }
213
214    /// Auto-save all modified buffers to their original files on disk
215    /// Returns the number of buffers saved
216    pub fn auto_save_persistent_buffers(&mut self) -> anyhow::Result<usize> {
217        if !self.config.editor.auto_save_enabled {
218            return Ok(0);
219        }
220
221        // Check if enough time has passed since last auto-save
222        let interval =
223            std::time::Duration::from_secs(self.config.editor.auto_save_interval_secs as u64);
224        if self
225            .time_source
226            .elapsed_since(self.active_window().last_persistent_auto_save)
227            < interval
228        {
229            return Ok(0);
230        }
231
232        self.active_window_mut().last_persistent_auto_save = self.time_source.now();
233
234        // Collect info for modified buffers that have a file path
235        let mut to_save = Vec::new();
236        for (id, state) in self
237            .windows
238            .get(&self.active_window)
239            .map(|w| &w.buffers)
240            .expect("active window present")
241        {
242            if state.buffer.is_modified() {
243                if let Some(path) = state.buffer.file_path() {
244                    to_save.push((*id, path.to_path_buf()));
245                }
246            }
247        }
248
249        let mut count = 0;
250        for (id, path) in to_save {
251            if let Some(state) = self
252                .windows
253                .get_mut(&self.active_window)
254                .map(|w| &mut w.buffers)
255                .expect("active window present")
256                .get_mut(&id)
257            {
258                match state.buffer.save() {
259                    Ok(()) => {
260                        self.finalize_save_buffer(id, Some(path), true)?;
261                        count += 1;
262                    }
263                    Err(e) => {
264                        // Skip if sudo is required (auto-save can't handle prompts)
265                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
266                            tracing::debug!(
267                                "Auto-save skipped for {:?} (sudo required)",
268                                path.display()
269                            );
270                        } else {
271                            tracing::warn!("Auto-save failed for {:?}: {}", path.display(), e);
272                        }
273                    }
274                }
275            }
276        }
277
278        Ok(count)
279    }
280
281    /// Collect ids of modified unnamed (no on-disk path) buffers in tab order.
282    ///
283    /// Used by the "save and quit" flow to walk a Save-As prompt over each
284    /// unnamed buffer before the editor actually exits.
285    pub(crate) fn collect_unnamed_modified_buffers(&self) -> Vec<BufferId> {
286        let mut out = Vec::new();
287        for (id, state) in self
288            .windows
289            .get(&self.active_window)
290            .map(|w| &w.buffers)
291            .expect("active window present")
292        {
293            if !state.buffer.is_modified() {
294                continue;
295            }
296            let is_unnamed = state
297                .buffer
298                .file_path()
299                .map(|p| p.as_os_str().is_empty())
300                .unwrap_or(true);
301            if is_unnamed {
302                out.push(*id);
303            }
304        }
305        out
306    }
307
308    /// Pop the next id from `pending_quit_unnamed_save` and start a Save-As
309    /// prompt for it. Returns true when a prompt was opened (and the editor
310    /// must keep running until the user finishes the chain).
311    pub(crate) fn start_next_quit_save_as(&mut self) -> bool {
312        while let Some(buffer_id) = self
313            .active_window_mut()
314            .pending_quit_unnamed_save
315            .first()
316            .copied()
317        {
318            // Skip ids that vanished or were already saved out from under us.
319            let still_dirty_unnamed = self
320                .buffers()
321                .get(&buffer_id)
322                .map(|s| {
323                    s.buffer.is_modified()
324                        && s.buffer
325                            .file_path()
326                            .map(|p| p.as_os_str().is_empty())
327                            .unwrap_or(true)
328                })
329                .unwrap_or(false);
330            if !still_dirty_unnamed {
331                self.active_window_mut().pending_quit_unnamed_save.remove(0);
332                continue;
333            }
334
335            self.set_active_buffer(buffer_id);
336            self.start_prompt(
337                t!("file.save_as_prompt").to_string(),
338                PromptType::SaveFileAs,
339            );
340            return true;
341        }
342        false
343    }
344
345    /// Save all modified file-backed buffers to disk (called on exit when auto_save is enabled).
346    /// Unlike `auto_save_persistent_buffers`, this skips the interval check and only saves
347    /// named file-backed buffers (not unnamed buffers).
348    pub fn save_all_on_exit(&mut self) -> anyhow::Result<usize> {
349        let mut to_save = Vec::new();
350        for (id, state) in self
351            .windows
352            .get(&self.active_window)
353            .map(|w| &w.buffers)
354            .expect("active window present")
355        {
356            if state.buffer.is_modified() {
357                if let Some(path) = state.buffer.file_path() {
358                    if !path.as_os_str().is_empty() {
359                        to_save.push((*id, path.to_path_buf()));
360                    }
361                }
362            }
363        }
364
365        let mut count = 0;
366        for (id, path) in to_save {
367            if let Some(state) = self
368                .windows
369                .get_mut(&self.active_window)
370                .map(|w| &mut w.buffers)
371                .expect("active window present")
372                .get_mut(&id)
373            {
374                match state.buffer.save() {
375                    Ok(()) => {
376                        self.finalize_save_buffer(id, Some(path), true)?;
377                        count += 1;
378                    }
379                    Err(e) => {
380                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
381                            tracing::debug!(
382                                "Auto-save on exit skipped for {} (sudo required)",
383                                path.display()
384                            );
385                        } else {
386                            tracing::warn!(
387                                "Auto-save on exit failed for {}: {}",
388                                path.display(),
389                                e
390                            );
391                        }
392                    }
393                }
394            }
395        }
396
397        Ok(count)
398    }
399
400    /// Revert the active buffer to the last saved version on disk
401    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
402    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
403        let path = match self.active_state().buffer.file_path() {
404            Some(p) => p.to_path_buf(),
405            None => {
406                self.active_window_mut().status_message =
407                    Some(t!("status.no_file_to_revert").to_string());
408                return Ok(false);
409            }
410        };
411
412        if !path.exists() {
413            self.active_window_mut().status_message =
414                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
415            return Ok(false);
416        }
417
418        // Save scroll position (from SplitViewState) and cursor positions before reloading
419        let active_split = self
420            .windows
421            .get(&self.active_window)
422            .and_then(|w| w.buffers.splits())
423            .map(|(mgr, _)| mgr)
424            .expect("active window must have a populated split layout")
425            .active_split();
426        let (old_top_byte, old_left_column) = self
427            .windows
428            .get(&self.active_window)
429            .and_then(|w| w.buffers.splits())
430            .map(|(_, vs)| vs)
431            .expect("active window must have a populated split layout")
432            .get(&active_split)
433            .map(|vs| (vs.viewport.top_byte, vs.viewport.left_column))
434            .unwrap_or((0, 0));
435        let old_cursors = self.active_cursors().clone();
436
437        // Preserve user settings before reloading
438        let old_buffer_settings = self.active_state().buffer_settings.clone();
439        let old_editing_disabled = self.active_state().editing_disabled;
440
441        // Load the file content fresh from disk
442        let mut new_state = EditorState::from_file_with_languages(
443            &path,
444            self.terminal_width,
445            self.terminal_height,
446            self.config.editor.large_file_threshold_bytes as usize,
447            &self.grammar_registry,
448            &self.config.languages,
449            std::sync::Arc::clone(&self.authority.filesystem),
450        )?;
451
452        // Restore cursor positions (clamped to valid range for new file size)
453        let new_file_size = new_state.buffer.len();
454        let mut restored_cursors = old_cursors;
455        restored_cursors.map(|cursor| {
456            cursor.position = cursor.position.min(new_file_size);
457            // Clear selection since the content may have changed
458            cursor.clear_selection();
459        });
460        // Restore user settings (tab size, indentation, etc.)
461        new_state.buffer_settings = old_buffer_settings;
462        new_state.editing_disabled = old_editing_disabled;
463        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
464
465        // Replace the current buffer with the new state
466        let buffer_id = self.active_buffer();
467        if let Some(state) = self
468            .windows
469            .get_mut(&self.active_window)
470            .map(|w| &mut w.buffers)
471            .expect("active window present")
472            .get_mut(&buffer_id)
473        {
474            *state = new_state;
475            // Note: line_wrap_enabled is now in SplitViewState.viewport
476        }
477
478        // Restore cursor positions in SplitViewState (clamped to valid range for new file size)
479        let active_split = self
480            .windows
481            .get(&self.active_window)
482            .and_then(|w| w.buffers.splits())
483            .map(|(mgr, _)| mgr)
484            .expect("active window must have a populated split layout")
485            .active_split();
486        if let Some(view_state) = self
487            .windows
488            .get_mut(&self.active_window)
489            .and_then(|w| w.split_view_states_mut())
490            .expect("active window must have a populated split layout")
491            .get_mut(&active_split)
492        {
493            view_state.cursors = restored_cursors;
494        }
495
496        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
497        if let Some(view_state) = self
498            .windows
499            .get_mut(&self.active_window)
500            .and_then(|w| w.split_view_states_mut())
501            .expect("active window must have a populated split layout")
502            .get_mut(&active_split)
503        {
504            view_state.viewport.top_byte = old_top_byte.min(new_file_size);
505            view_state.viewport.left_column = old_left_column;
506        }
507
508        // Clear the undo/redo history for this buffer
509        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
510            *event_log = EventLog::new();
511        }
512
513        // Clear seen_byte_ranges so plugins get notified of all visible lines
514        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);
515
516        // Update the file modification time
517        if let Ok(metadata) = self.authority.filesystem.metadata(&path) {
518            if let Some(mtime) = metadata.modified {
519                self.file_mod_times_mut().insert(path.clone(), mtime);
520            }
521        }
522
523        // Notify LSP that the file was changed
524        self.notify_lsp_file_changed(&path);
525
526        self.active_window_mut().status_message = Some(t!("status.reverted").to_string());
527        Ok(true)
528    }
529
530    /// Toggle auto-revert mode
531    pub fn toggle_auto_revert(&mut self) {
532        self.active_window_mut().auto_revert_enabled = !self.active_window().auto_revert_enabled;
533
534        if self.active_window().auto_revert_enabled {
535            self.active_window_mut().status_message =
536                Some(t!("status.auto_revert_enabled").to_string());
537        } else {
538            self.active_window_mut().status_message =
539                Some(t!("status.auto_revert_disabled").to_string());
540        }
541    }
542
543    /// Poll for file changes (called from main loop)
544    ///
545    /// Checks modification times of open files to detect external changes.
546    /// Returns true if any file was changed (requires re-render).
547    ///
548    /// To avoid blocking the event loop, metadata checks run on a background
549    /// thread. This method launches a poll if the interval has elapsed and no
550    /// poll is already in flight, then checks for results from a prior poll.
551    pub fn poll_file_changes(&mut self) -> bool {
552        // Skip if auto-revert is disabled
553        if !self.active_window().auto_revert_enabled {
554            return false;
555        }
556
557        // Check for results from a previous background poll
558        let mut any_changed = false;
559        if let Some(ref rx) = self.active_window_mut().pending_file_poll_rx {
560            match rx.try_recv() {
561                Ok(results) => {
562                    self.active_window_mut().pending_file_poll_rx = None;
563                    any_changed = self.process_file_poll_results(results);
564                }
565                Err(std::sync::mpsc::TryRecvError::Empty) => {
566                    // Still in progress — don't block, don't start another
567                    return false;
568                }
569                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
570                    // Background task panicked or was dropped
571                    self.active_window_mut().pending_file_poll_rx = None;
572                }
573            }
574        }
575
576        // Check poll interval
577        let poll_interval =
578            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
579        let last_poll = self.active_window().last_auto_revert_poll;
580        let elapsed = self.time_source.elapsed_since(last_poll);
581        tracing::trace!(
582            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
583            elapsed,
584            poll_interval
585        );
586        if elapsed < poll_interval {
587            return any_changed;
588        }
589        self.active_window_mut().last_auto_revert_poll = self.time_source.now();
590
591        // Collect paths of open files that need checking
592        let files_to_check: Vec<PathBuf> = self.buffers().paths();
593
594        if files_to_check.is_empty() {
595            return any_changed;
596        }
597
598        // Spawn background metadata checks
599        let (tx, rx) = std::sync::mpsc::channel();
600        let fs = self.authority.filesystem.clone();
601        std::thread::Builder::new()
602            .name("poll-file-changes".to_string())
603            .spawn(move || {
604                let results: Vec<(PathBuf, Option<std::time::SystemTime>)> = files_to_check
605                    .into_iter()
606                    .map(|path| {
607                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
608                        (path, mtime)
609                    })
610                    .collect();
611                // Receiver may have been dropped if auto-revert was disabled
612                // or the editor is shutting down — that's fine.
613                if tx.send(results).is_err() {}
614            })
615            .ok();
616        self.active_window_mut().pending_file_poll_rx = Some(rx);
617
618        any_changed
619    }
620
621    /// Process results from a background file poll
622    fn process_file_poll_results(
623        &mut self,
624        results: Vec<(PathBuf, Option<std::time::SystemTime>)>,
625    ) -> bool {
626        let mut any_changed = false;
627        for (path, mtime_opt) in results {
628            let Some(current_mtime) = mtime_opt else {
629                continue;
630            };
631
632            if let Some(&stored_mtime) = self.file_mod_times().get(&path) {
633                if current_mtime != stored_mtime {
634                    let path_str = path.display().to_string();
635                    if self.handle_async_file_changed(path_str) {
636                        any_changed = true;
637                    }
638                }
639            } else {
640                // First time seeing this file, record its mtime
641                self.file_mod_times_mut().insert(path, current_mtime);
642            }
643        }
644        any_changed
645    }
646
647    /// Poll for file tree changes (called from main loop)
648    ///
649    /// Checks modification times of expanded directories to detect new/deleted files.
650    /// Returns true if any directory was refreshed (requires re-render).
651    ///
652    /// Like poll_file_changes, metadata checks run on a background thread to
653    /// avoid blocking the event loop.
654    pub fn poll_file_tree_changes(&mut self) -> bool {
655        use crate::view::file_tree::NodeId;
656
657        // Check for results from a previous background poll
658        let mut any_refreshed = false;
659        let mut dir_poll_pending = false;
660        if let Some(ref rx) = self.active_window_mut().pending_dir_poll_rx {
661            match rx.try_recv() {
662                Ok((dir_results, git_index_mtime)) => {
663                    self.active_window_mut().pending_dir_poll_rx = None;
664                    any_refreshed = self.process_dir_poll_results(dir_results, git_index_mtime);
665                }
666                Err(std::sync::mpsc::TryRecvError::Empty) => {
667                    dir_poll_pending = true;
668                }
669                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
670                    self.active_window_mut().pending_dir_poll_rx = None;
671                }
672            }
673        }
674
675        // Check poll interval
676        let poll_interval =
677            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
678        let last_tree_poll = self.active_window().last_file_tree_poll;
679        if self.time_source.elapsed_since(last_tree_poll) < poll_interval {
680            return any_refreshed;
681        }
682        self.active_window_mut().last_file_tree_poll = self.time_source.now();
683
684        // Re-stat every loaded .gitignore and reload/drop as needed, so
685        // external edits (git pull, sed, another editor) and deletions take
686        // effect without a restart. In-editor saves already reload eagerly
687        // via finalize_save_buffer. Sync I/O here — a handful of small files
688        // and all access goes through the filesystem authority.
689        if self.sync_gitignores_from_disk() {
690            any_refreshed = true;
691        }
692
693        // If a previous dir-poll is still in flight, don't stack another.
694        if dir_poll_pending {
695            return any_refreshed;
696        }
697
698        // Resolve the git index path once (first poll only). This uses the
699        // ProcessSpawner which may block briefly on the first call, but only
700        // happens once per session.
701        if !self.active_window_mut().git_index_resolved {
702            self.active_window_mut().git_index_resolved = true;
703            if let Some(path) = self.resolve_git_index() {
704                if let Ok(meta) = self.authority.filesystem.metadata(&path) {
705                    if let Some(mtime) = meta.modified {
706                        self.active_window_mut().dir_mod_times.insert(path, mtime);
707                    }
708                }
709            }
710        }
711
712        // Get file explorer reference
713        let Some(explorer) = self.file_explorer() else {
714            return any_refreshed;
715        };
716
717        // Collect expanded directories (node_id, path)
718        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
719            .tree()
720            .all_nodes()
721            .filter(|node| node.is_dir() && node.is_expanded())
722            .map(|node| (node.id, node.entry.path.clone()))
723            .collect();
724
725        // Find the git index path to include in the background metadata check
726        let git_index_path: Option<PathBuf> = self
727            .active_window()
728            .dir_mod_times
729            .keys()
730            .find(|p| p.ends_with(".git/index") || p.ends_with(".git\\index"))
731            .cloned();
732
733        if expanded_dirs.is_empty() && git_index_path.is_none() {
734            return any_refreshed;
735        }
736
737        // Spawn background metadata checks (directories + git index)
738        let (tx, rx) = std::sync::mpsc::channel();
739        let fs = self.authority.filesystem.clone();
740        std::thread::Builder::new()
741            .name("poll-dir-changes".to_string())
742            .spawn(move || {
743                let results: Vec<(NodeId, PathBuf, Option<std::time::SystemTime>)> = expanded_dirs
744                    .into_iter()
745                    .map(|(node_id, path)| {
746                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
747                        (node_id, path, mtime)
748                    })
749                    .collect();
750
751                // Also check git index mtime in the same background thread
752                let git_index_mtime = git_index_path.and_then(|path| {
753                    let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
754                    Some((path, mtime?))
755                });
756
757                // Receiver may have been dropped during shutdown — that's fine.
758                if tx.send((results, git_index_mtime)).is_err() {}
759            })
760            .ok();
761        self.active_window_mut().pending_dir_poll_rx = Some(rx);
762
763        any_refreshed
764    }
765
766    /// Process results from a background directory poll
767    fn process_dir_poll_results(
768        &mut self,
769        results: Vec<(
770            crate::view::file_tree::NodeId,
771            PathBuf,
772            Option<std::time::SystemTime>,
773        )>,
774        git_index_mtime: Option<(PathBuf, std::time::SystemTime)>,
775    ) -> bool {
776        let mut dirs_to_refresh: Vec<(crate::view::file_tree::NodeId, PathBuf)> = Vec::new();
777
778        for (node_id, path, mtime_opt) in results {
779            let Some(current_mtime) = mtime_opt else {
780                continue;
781            };
782
783            if let Some(&stored_mtime) = self.active_window_mut().dir_mod_times.get(&path) {
784                if current_mtime != stored_mtime {
785                    self.active_window_mut()
786                        .dir_mod_times
787                        .insert(path.clone(), current_mtime);
788                    dirs_to_refresh.push((node_id, path.clone()));
789                    tracing::debug!("Directory changed: {:?}", path);
790                }
791            } else {
792                self.active_window_mut()
793                    .dir_mod_times
794                    .insert(path, current_mtime);
795            }
796        }
797
798        // Check if .git/index mtime changed (detected in background thread)
799        let git_index_changed = if let Some((path, current_mtime)) = git_index_mtime {
800            if let Some(&stored_mtime) = self.active_window_mut().dir_mod_times.get(&path) {
801                if current_mtime != stored_mtime {
802                    self.active_window_mut()
803                        .dir_mod_times
804                        .insert(path, current_mtime);
805                    self.plugin_manager.read().unwrap().run_hook(
806                        "focus_gained",
807                        crate::services::plugins::hooks::HookArgs::FocusGained {},
808                    );
809                    true
810                } else {
811                    false
812                }
813            } else {
814                false
815            }
816        } else {
817            false
818        };
819
820        if dirs_to_refresh.is_empty() && !git_index_changed {
821            return false;
822        }
823
824        // Refresh each changed directory and (re)load its .gitignore. A new
825        // .gitignore file inside an expanded dir bumps the dir's mtime so we
826        // land here; reload_expanded_node re-lists entries but doesn't parse
827        // rules — load_gitignore_via_fs handles the rules side.
828        let refreshed_dirs: Vec<PathBuf> = dirs_to_refresh.iter().map(|(_, p)| p.clone()).collect();
829        self.refresh_file_tree_dirs(&refreshed_dirs);
830        let fs = self.authority.filesystem.clone();
831        if let Some(explorer) = self.file_explorer_mut().as_mut() {
832            for dir in refreshed_dirs {
833                load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
834            }
835        }
836
837        true
838    }
839
840    /// Re-read the given directories in the file explorer, preserving
841    /// descendant expansion state and the cursor's path.
842    ///
843    /// Why reload_expanded_node and not refresh_node: refresh_node
844    /// collapses the directory and re-expands it, which recycles every
845    /// descendant NodeId and drops their expansion state. That's fatal
846    /// for this code path, which runs unprompted from a background timer:
847    /// after a cut+paste into the workspace root, the source parent's
848    /// mtime changes, we land here seconds later, and refresh_node would
849    /// collapse a user-expanded subtree and invalidate the cursor
850    /// NodeId (after which Up/Down become no-ops because
851    /// select_next/select_prev can't find the current id in the visible
852    /// list).
853    ///
854    /// We also snapshot the cursor path before the reload and
855    /// re-resolve it afterwards. reload_expanded_node still recycles
856    /// ids under the refreshed root, so the old id is gone; the path
857    /// survives unless the underlying file was deleted, in which case
858    /// we fall back to the root so the cursor stays live and visible
859    /// (a stale id is effectively no cursor at all).
860    ///
861    /// Exposed as `pub` so tests can drive the refresh path directly
862    /// without relying on filesystem mtime detection, which is too
863    /// environment-sensitive (especially across CI filesystems).
864    pub fn refresh_file_tree_dirs(&mut self, paths: &[PathBuf]) {
865        let active_id = self.active_window;
866        let (Some(runtime), Some(explorer)) = (
867            self.tokio_runtime.as_ref(),
868            self.windows
869                .get_mut(&active_id)
870                .and_then(|w| w.file_explorer.as_mut()),
871        ) else {
872            return;
873        };
874        let cursor_path: Option<PathBuf> = explorer.get_selected_entry().map(|e| e.path.clone());
875        // Re-resolve node ids by path at each step: an earlier
876        // reload_expanded_node in this loop may have recycled ids under
877        // its subtree, so any ids captured before this call can be
878        // stale.
879        for path in paths {
880            let Some(id_now) = explorer.tree().get_node_by_path(path).map(|n| n.id) else {
881                continue;
882            };
883            let tree = explorer.tree_mut();
884            if let Err(e) = runtime.block_on(tree.reload_expanded_node(id_now)) {
885                tracing::warn!("Failed to refresh directory {:?}: {}", path, e);
886            }
887        }
888        if let Some(path) = cursor_path {
889            if explorer.tree().get_node_by_path(&path).is_some() {
890                explorer.navigate_to_path(&path);
891            } else {
892                let root_id = explorer.tree().root_id();
893                explorer.set_selected(Some(root_id));
894            }
895        }
896    }
897
898    /// Re-stat every loaded .gitignore via the filesystem authority and
899    /// reload or drop as needed. Returns true if anything changed.
900    fn sync_gitignores_from_disk(&mut self) -> bool {
901        let fs = self.authority.filesystem.clone();
902        let Some(explorer) = self.file_explorer_mut() else {
903            return false;
904        };
905        let dirs = explorer.ignore_patterns().loaded_gitignore_dirs();
906        let mut changed = false;
907        for dir in dirs {
908            let gitignore_path = dir.join(".gitignore");
909            match fs.metadata(&gitignore_path) {
910                Err(_) => {
911                    explorer.ignore_patterns_mut().remove_gitignore(&dir);
912                    changed = true;
913                }
914                Ok(meta) => {
915                    let stored = explorer.ignore_patterns().stored_gitignore_mtime(&dir);
916                    if stored != meta.modified {
917                        load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
918                        changed = true;
919                    }
920                }
921            }
922        }
923        changed
924    }
925
926    /// Resolve the path to `.git/index` via `git rev-parse --git-dir`.
927    /// Uses the `ProcessSpawner` so it works transparently on both local
928    /// and remote (SSH) filesystems.
929    fn resolve_git_index(&self) -> Option<PathBuf> {
930        let spawner = &self.authority.process_spawner;
931        let cwd = self.working_dir().to_string_lossy().to_string();
932
933        // ProcessSpawner is async — run it on the tokio runtime if available,
934        // otherwise fall back to blocking (should only happen in tests without
935        // a runtime).
936        let result = if let Some(ref rt) = self.tokio_runtime {
937            rt.block_on(spawner.spawn(
938                "git".to_string(),
939                vec!["rev-parse".to_string(), "--git-dir".to_string()],
940                Some(cwd),
941            ))
942        } else {
943            // No runtime — can't run async spawner. This shouldn't happen
944            // in production but can in minimal test setups.
945            return None;
946        };
947
948        let output = result.ok()?;
949        if output.exit_code != 0 {
950            return None;
951        }
952        let git_dir = output.stdout.trim();
953        let git_dir_path = if std::path::Path::new(git_dir).is_absolute() {
954            PathBuf::from(git_dir)
955        } else {
956            self.working_dir().join(git_dir)
957        };
958        Some(git_dir_path.join("index"))
959    }
960
961    /// Notify LSP server about a newly opened file
962    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
963    ///
964    /// Thin delegator: the LSP-notify core lives on `impl Window` (it
965    /// reads only the window's own `lsp` / `buffers` /
966    /// `diagnostic_result_ids` + `self.resources`). Editor callers
967    /// forward to the active window.
968    pub(crate) fn notify_lsp_file_opened(
969        &mut self,
970        path: &Path,
971        buffer_id: BufferId,
972        metadata: &mut BufferMetadata,
973    ) {
974        self.active_window_mut()
975            .notify_lsp_file_opened(path, buffer_id, metadata);
976    }
977
978    /// Record a file's modification time (called when opening files)
979    /// This is used by the polling-based auto-revert to detect external changes.
980    ///
981    /// Thin delegator: `watch_file` is window-local (records into the
982    /// active window's `file_mod_times`); forwards to the active window.
983    pub(crate) fn watch_file(&mut self, path: &Path) {
984        self.active_window_mut().watch_file(path);
985    }
986
987    /// Notify LSP that a file's contents changed (e.g., after revert)
988    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
989        use crate::services::lsp::manager::LspSpawnResult;
990
991        let Some(lsp_uri) = super::types::file_path_to_lsp_uri_with_translation(
992            path,
993            self.authority.path_translation.as_ref(),
994        ) else {
995            return;
996        };
997
998        // Find the buffer ID, content, and language for this path
999        let Some((buffer_id, content, language)) = self
1000            .buffers()
1001            .iter()
1002            .find(|(_, s)| s.buffer.file_path() == Some(path))
1003            .and_then(|(id, state)| {
1004                state
1005                    .buffer
1006                    .to_string()
1007                    .map(|t| (*id, t, state.language.clone()))
1008            })
1009        else {
1010            return;
1011        };
1012
1013        // Check if we can spawn LSP (respects auto_start setting)
1014        let spawn_result = {
1015            let __active_id = self.active_window;
1016            let Some(lsp) = self
1017                .windows
1018                .get_mut(&__active_id)
1019                .and_then(|w| w.lsp.as_mut())
1020            else {
1021                return;
1022            };
1023            lsp.try_spawn(&language, Some(path))
1024        };
1025
1026        // Only proceed if spawned successfully (or already running)
1027        if spawn_result != LspSpawnResult::Spawned {
1028            return;
1029        }
1030
1031        // Send didOpen to any handles that haven't received it yet
1032        {
1033            let opened_with = self
1034                .active_window()
1035                .buffer_metadata
1036                .get(&buffer_id)
1037                .map(|m| m.lsp_opened_with.clone())
1038                .unwrap_or_default();
1039
1040            let __active_id = self.active_window;
1041
1042            if let Some(lsp) = self
1043                .windows
1044                .get_mut(&__active_id)
1045                .and_then(|w| w.lsp.as_mut())
1046            {
1047                for sh in lsp.get_handles_mut(&language) {
1048                    if opened_with.contains(&sh.handle.id()) {
1049                        continue;
1050                    }
1051                    if let Err(e) =
1052                        sh.handle
1053                            .did_open(lsp_uri.clone(), content.clone(), language.clone())
1054                    {
1055                        tracing::warn!(
1056                            "Failed to send didOpen to LSP '{}' before didChange: {}",
1057                            sh.name,
1058                            e
1059                        );
1060                    } else {
1061                        tracing::debug!(
1062                            "Sent didOpen for {} to LSP '{}' before file change notification",
1063                            lsp_uri.as_str(),
1064                            sh.name
1065                        );
1066                    }
1067                }
1068            }
1069
1070            // Mark all handles as opened
1071            let active_id = self.active_window;
1072            if let Some(__win) = self.windows.get_mut(&active_id) {
1073                if let (Some(lsp), Some(metadata)) = (
1074                    __win.lsp.as_ref(),
1075                    __win.buffer_metadata.get_mut(&buffer_id),
1076                ) {
1077                    for sh in lsp.get_handles(&language) {
1078                        metadata.lsp_opened_with.insert(sh.handle.id());
1079                    }
1080                }
1081            }
1082        }
1083
1084        // Use full document sync - broadcast to all handles
1085        let __active_id = self.active_window;
1086        if let Some(lsp) = self
1087            .windows
1088            .get_mut(&__active_id)
1089            .and_then(|w| w.lsp.as_mut())
1090        {
1091            let content_change = TextDocumentContentChangeEvent {
1092                range: None, // None means full document replacement
1093                range_length: None,
1094                text: content,
1095            };
1096            for sh in lsp.get_handles_mut(&language) {
1097                if let Err(e) = sh
1098                    .handle
1099                    .did_change(lsp_uri.clone(), vec![content_change.clone()])
1100                {
1101                    tracing::warn!("Failed to notify LSP '{}' of file change: {}", sh.name, e);
1102                }
1103            }
1104        }
1105    }
1106
1107    /// Revert a specific buffer by ID without affecting the active viewport.
1108    ///
1109    /// This is used for auto-reverting background buffers that aren't currently
1110    /// visible in the active split. It reloads the buffer content and updates
1111    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
1112    pub(crate) fn revert_buffer_by_id(
1113        &mut self,
1114        buffer_id: BufferId,
1115        path: &Path,
1116    ) -> anyhow::Result<()> {
1117        // Preserve user settings before reloading
1118        // TODO: Consider moving line numbers to SplitViewState (per-view setting)
1119        // Get cursors from split view states for this buffer (find any split showing it)
1120        let old_cursors = self
1121            .windows
1122            .get(&self.active_window)
1123            .and_then(|w| w.buffers.splits())
1124            .map(|(_, vs)| vs)
1125            .expect("active window must have a populated split layout")
1126            .values()
1127            .find_map(|vs| {
1128                if vs.keyed_states.contains_key(&buffer_id) {
1129                    vs.keyed_states.get(&buffer_id).map(|bs| bs.cursors.clone())
1130                } else {
1131                    None
1132                }
1133            })
1134            .unwrap_or_default();
1135        let (old_buffer_settings, old_editing_disabled) = self
1136            .buffers()
1137            .get(&buffer_id)
1138            .map(|s| (s.buffer_settings.clone(), s.editing_disabled))
1139            .unwrap_or_default();
1140
1141        // Load the file content fresh from disk
1142        let mut new_state = EditorState::from_file_with_languages(
1143            path,
1144            self.terminal_width,
1145            self.terminal_height,
1146            self.config.editor.large_file_threshold_bytes as usize,
1147            &self.grammar_registry,
1148            &self.config.languages,
1149            std::sync::Arc::clone(&self.authority.filesystem),
1150        )?;
1151
1152        // Get the new file size for clamping
1153        let new_file_size = new_state.buffer.len();
1154
1155        // Restore cursor positions (clamped to valid range for new file size)
1156        let mut restored_cursors = old_cursors;
1157        restored_cursors.map(|cursor| {
1158            cursor.position = cursor.position.min(new_file_size);
1159            cursor.clear_selection();
1160        });
1161        // Restore user settings (tab size, indentation, etc.)
1162        new_state.buffer_settings = old_buffer_settings;
1163        new_state.editing_disabled = old_editing_disabled;
1164        // Line number visibility is in per-split BufferViewState (survives buffer replacement)
1165
1166        // Replace the buffer content
1167        if let Some(state) = self
1168            .windows
1169            .get_mut(&self.active_window)
1170            .map(|w| &mut w.buffers)
1171            .expect("active window present")
1172            .get_mut(&buffer_id)
1173        {
1174            *state = new_state;
1175        }
1176
1177        // Restore cursors in any split view states that have this buffer
1178        for vs in self
1179            .windows
1180            .get_mut(&self.active_window)
1181            .and_then(|w| w.split_view_states_mut())
1182            .expect("active window must have a populated split layout")
1183            .values_mut()
1184        {
1185            if let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) {
1186                buf_state.cursors = restored_cursors.clone();
1187            }
1188        }
1189
1190        // Clear the undo/redo history for this buffer
1191        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1192            *event_log = EventLog::new();
1193        }
1194
1195        // Clear seen_byte_ranges so plugins get notified of all visible lines
1196        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);
1197
1198        // Update the file modification time
1199        if let Ok(metadata) = self.authority.filesystem.metadata(path) {
1200            if let Some(mtime) = metadata.modified {
1201                self.file_mod_times_mut().insert(path.to_path_buf(), mtime);
1202            }
1203        }
1204
1205        // Notify LSP that the file was changed
1206        self.notify_lsp_file_changed(path);
1207
1208        Ok(())
1209    }
1210
1211    /// Handle a file change notification (from file watcher)
1212    pub fn handle_file_changed(&mut self, changed_path: &str) {
1213        let path = PathBuf::from(changed_path);
1214
1215        // Find buffers that have this file open
1216        let buffer_ids: Vec<BufferId> = self
1217            .buffers()
1218            .iter()
1219            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
1220            .map(|(id, _)| *id)
1221            .collect();
1222
1223        if buffer_ids.is_empty() {
1224            return;
1225        }
1226
1227        for buffer_id in buffer_ids {
1228            // Skip terminal buffers - they manage their own content via PTY streaming
1229            // and should not be auto-reverted (which would reset editing_disabled and line_numbers)
1230            if self
1231                .active_window()
1232                .terminal_buffers
1233                .contains_key(&buffer_id)
1234            {
1235                continue;
1236            }
1237
1238            // Skip buffers that have opted out of auto-revert. The
1239            // typical caller is `openFileStreaming`, which manages
1240            // appends itself via `extend_streaming`; an auto-revert
1241            // here would race with those appends, wiping the piece
1242            // tree mid-stream and snapping the cursor back to byte 0
1243            // on every kernel write notification.
1244            if let Some(meta) = self.active_window().buffer_metadata.get(&buffer_id) {
1245                if !meta.auto_revert_enabled {
1246                    continue;
1247                }
1248            }
1249
1250            let state = match self
1251                .windows
1252                .get(&self.active_window)
1253                .map(|w| &w.buffers)
1254                .expect("active window present")
1255                .get(&buffer_id)
1256            {
1257                Some(s) => s,
1258                None => continue,
1259            };
1260
1261            // Check if the file actually changed (compare mod times)
1262            // We use optimistic concurrency: check mtime, and if we decide to revert,
1263            // re-check to handle the race where a save completed between our checks.
1264            let current_mtime = match self
1265                .authority
1266                .filesystem
1267                .metadata(&path)
1268                .ok()
1269                .and_then(|m| m.modified)
1270            {
1271                Some(mtime) => mtime,
1272                None => continue, // Can't read file, skip
1273            };
1274
1275            let dominated_by_stored = self
1276                .file_mod_times()
1277                .get(&path)
1278                .map(|stored| current_mtime <= *stored)
1279                .unwrap_or(false);
1280
1281            if dominated_by_stored {
1282                continue;
1283            }
1284
1285            // If buffer has local modifications, show a warning (don't auto-revert)
1286            if state.buffer.is_modified() {
1287                self.active_window_mut().status_message = Some(format!(
1288                    "File {} changed on disk (buffer has unsaved changes)",
1289                    path.display()
1290                ));
1291                continue;
1292            }
1293
1294            // Auto-revert if enabled and buffer is not modified
1295            if self.active_window().auto_revert_enabled {
1296                // Optimistic concurrency: re-check mtime before reverting.
1297                // A save may have completed between our first check and now,
1298                // updating file_mod_times. If so, skip the revert.
1299                let still_needs_revert = self
1300                    .file_mod_times()
1301                    .get(&path)
1302                    .map(|stored| current_mtime > *stored)
1303                    .unwrap_or(true);
1304
1305                if !still_needs_revert {
1306                    continue;
1307                }
1308
1309                // Check if this buffer is currently displayed in the active split
1310                let is_active_buffer = buffer_id == self.active_buffer();
1311
1312                if is_active_buffer {
1313                    // Use revert_file() which preserves viewport for active buffer
1314                    if let Err(e) = self.revert_file() {
1315                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
1316                    } else {
1317                        tracing::info!("Auto-reverted file: {:?}", path);
1318                    }
1319                } else {
1320                    // Use revert_buffer_by_id() which doesn't touch any viewport
1321                    // This prevents corrupting the active split's viewport state
1322                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
1323                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
1324                    } else {
1325                        tracing::info!("Auto-reverted file: {:?}", path);
1326                    }
1327                }
1328
1329                // Update the modification time tracking for this file
1330                self.watch_file(&path);
1331            }
1332        }
1333    }
1334
1335    /// Check if saving would overwrite changes made by another process
1336    /// Returns Some(current_mtime) if there's a conflict, None otherwise
1337    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
1338        let path = self.active_state().buffer.file_path()?;
1339
1340        // Get current file modification time
1341        let current_mtime = self
1342            .authority
1343            .filesystem
1344            .metadata(path)
1345            .ok()
1346            .and_then(|m| m.modified)?;
1347
1348        // Compare with our recorded modification time
1349        match self.file_mod_times().get(path) {
1350            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
1351                // File was modified externally since we last loaded/saved it
1352                Some(current_mtime)
1353            }
1354            _ => None,
1355        }
1356    }
1357}
1358
1359/// Stat and read `dir/.gitignore` via the filesystem authority and install
1360/// the result on `explorer`. No-op (with a warn-level log on unexpected
1361/// errors) when the file doesn't exist. Shared by the init, expand, save,
1362/// and poll paths so everything routes through the same authority.
1363pub(crate) fn load_gitignore_via_fs(fs: &dyn FileSystem, explorer: &mut FileTreeView, dir: &Path) {
1364    let gitignore_path = dir.join(".gitignore");
1365    let meta = match fs.metadata(&gitignore_path) {
1366        Ok(m) => m,
1367        Err(_) => return,
1368    };
1369    let bytes = match fs.read_file(&gitignore_path) {
1370        Ok(b) => b,
1371        Err(e) => {
1372            tracing::warn!("Failed to read {:?}: {}", gitignore_path, e);
1373            return;
1374        }
1375    };
1376    explorer.load_gitignore_from_bytes(dir, &bytes, meta.modified);
1377}
1378
1379impl crate::app::window::Window {
1380    /// Notify this window's LSP servers about a newly opened file.
1381    ///
1382    /// Window-scoped: reads only the window's own `lsp`, `buffers`,
1383    /// `diagnostic_result_ids`, and `self.resources.{authority,config}`.
1384    /// Handles language detection, spawning LSP clients, and sending
1385    /// didOpen notifications + follow-up pull-diagnostics / inlay-hints.
1386    pub(crate) fn notify_lsp_file_opened(
1387        &mut self,
1388        path: &Path,
1389        buffer_id: BufferId,
1390        metadata: &mut BufferMetadata,
1391    ) {
1392        // Get language from buffer state
1393        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
1394            tracing::debug!("No buffer state for file: {}", path.display());
1395            return;
1396        };
1397
1398        let Some(uri) = metadata.file_uri().cloned() else {
1399            tracing::warn!(
1400                "No URI in metadata for file: {} (failed to compute absolute path)",
1401                path.display()
1402            );
1403            return;
1404        };
1405
1406        // Check file size
1407        let file_size = self
1408            .resources
1409            .authority
1410            .filesystem
1411            .metadata(path)
1412            .ok()
1413            .map(|m| m.size)
1414            .unwrap_or(0);
1415        if file_size > self.resources.config.editor.large_file_threshold_bytes {
1416            let reason = format!("File too large ({} bytes)", file_size);
1417            tracing::debug!(
1418                "Skipping LSP for large file: {} ({})",
1419                path.display(),
1420                reason
1421            );
1422            metadata.disable_lsp(reason);
1423            return;
1424        }
1425
1426        // Get text before borrowing lsp
1427        let text = match self
1428            .buffers
1429            .get(&buffer_id)
1430            .and_then(|state| state.buffer.to_string())
1431        {
1432            Some(t) => t,
1433            None => {
1434                tracing::debug!("Buffer not fully loaded for LSP notification");
1435                return;
1436            }
1437        };
1438
1439        let enable_inlay_hints = self.resources.config.editor.enable_inlay_hints;
1440        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
1441
1442        // Get buffer line count and version for inlay hints
1443        let (last_line, last_char, buffer_version) = self
1444            .buffers
1445            .get(&buffer_id)
1446            .map(|state| {
1447                let line_count = state.buffer.line_count().unwrap_or(1000);
1448                (
1449                    line_count.saturating_sub(1) as u32,
1450                    10000u32,
1451                    state.buffer.version(),
1452                )
1453            })
1454            .unwrap_or((999, 10000, 0));
1455
1456        // Now borrow lsp and do all LSP operations
1457        let Some(lsp) = self.lsp.as_mut() else {
1458            tracing::debug!("No LSP manager available");
1459            return;
1460        };
1461        let __next_id = &mut self.next_lsp_request_id;
1462
1463        tracing::debug!("LSP manager available for file: {}", path.display());
1464        tracing::debug!(
1465            "Detected language: {} for file: {}",
1466            language,
1467            path.display()
1468        );
1469        tracing::debug!("Using URI from metadata: {}", uri.as_str());
1470        tracing::debug!("Attempting to spawn LSP client for language: {}", language);
1471
1472        match lsp.try_spawn(&language, Some(path)) {
1473            LspSpawnResult::Spawned => {
1474                // Send didOpen to ALL server handles for this language,
1475                // not just the first one.  With multiple servers configured
1476                // (e.g. error-server + warning-server) each needs to know
1477                // about the open document.
1478                for sh in lsp.get_handles_mut(&language) {
1479                    tracing::info!("Sending didOpen to LSP '{}' for: {}", sh.name, uri.as_str());
1480                    if let Err(e) =
1481                        sh.handle
1482                            .did_open(uri.as_uri().clone(), text.clone(), language.clone())
1483                    {
1484                        tracing::warn!("Failed to send didOpen to LSP '{}': {}", sh.name, e);
1485                    } else {
1486                        metadata.lsp_opened_with.insert(sh.handle.id());
1487                    }
1488                }
1489
1490                // Route each follow-up request through capability-aware
1491                // routing so we never send an optional method to a server
1492                // that didn't advertise it. On a cold spawn the capability
1493                // check returns `None` (capabilities aren't known until the
1494                // `initialize` response arrives); the `LspInitialized`
1495                // handler replays these requests once capabilities land.
1496                if let Some(sh) =
1497                    lsp.handle_for_feature_mut(&language, crate::types::LspFeature::Diagnostics)
1498                {
1499                    let request_id = {
1500                        let id = *__next_id;
1501                        *__next_id += 1;
1502                        id
1503                    };
1504                    if let Err(e) = sh.handle.document_diagnostic(
1505                        request_id,
1506                        uri.as_uri().clone(),
1507                        previous_result_id,
1508                    ) {
1509                        tracing::debug!("Failed to request pull diagnostics: {}", e);
1510                    } else {
1511                        tracing::info!(
1512                            "Requested pull diagnostics for {} (request_id={})",
1513                            uri.as_str(),
1514                            request_id
1515                        );
1516                    }
1517                }
1518
1519                if enable_inlay_hints {
1520                    if let Some(sh) =
1521                        lsp.handle_for_feature_mut(&language, crate::types::LspFeature::InlayHints)
1522                    {
1523                        let request_id = {
1524                            let id = *__next_id;
1525                            *__next_id += 1;
1526                            id
1527                        };
1528
1529                        if let Err(e) = sh.handle.inlay_hints(
1530                            request_id,
1531                            uri.as_uri().clone(),
1532                            0,
1533                            0,
1534                            last_line,
1535                            last_char,
1536                        ) {
1537                            tracing::debug!("Failed to request inlay hints: {}", e);
1538                        } else {
1539                            self.pending_inlay_hints_requests.insert(
1540                                request_id,
1541                                super::InlayHintsRequest {
1542                                    buffer_id,
1543                                    version: buffer_version,
1544                                },
1545                            );
1546                            tracing::info!(
1547                                "Requested inlay hints for {} (request_id={})",
1548                                uri.as_str(),
1549                                request_id
1550                            );
1551                        }
1552                    }
1553                }
1554
1555                // Schedule folding range refresh
1556                self.schedule_folding_ranges_refresh(buffer_id);
1557            }
1558            LspSpawnResult::NotAutoStart => {
1559                tracing::debug!(
1560                    "LSP for {} not auto-starting (auto_start=false). Click the LSP indicator to start manually.",
1561                    language
1562                );
1563            }
1564            LspSpawnResult::NotConfigured => {
1565                tracing::debug!("No LSP server configured for language: {}", language);
1566            }
1567            LspSpawnResult::Disabled => {
1568                tracing::debug!("LSP disabled in config for language: {}", language);
1569            }
1570            LspSpawnResult::Failed => {
1571                tracing::warn!("Failed to spawn LSP client for language: {}", language);
1572            }
1573        }
1574    }
1575
1576    /// Record a file's modification time (called when opening files).
1577    /// Window-local: records into this window's own `file_mod_times`.
1578    pub(crate) fn watch_file(&mut self, path: &Path) {
1579        if let Ok(metadata) = self.resources.authority.filesystem.metadata(path) {
1580            if let Some(mtime) = metadata.modified {
1581                self.file_mod_times.insert(path.to_path_buf(), mtime);
1582            }
1583        }
1584    }
1585}