Skip to main content

fresh/app/
buffer_management.rs

1//! Buffer management operations for the Editor.
2//!
3//! This module contains all methods related to buffer lifecycle and navigation:
4//! - Opening files (with and without focus)
5//! - Creating new buffers (regular and virtual)
6//! - Closing buffers and tabs
7//! - Switching between buffers
8//! - Navigate back/forward in position history
9//! - Buffer state persistence
10
11use anyhow::Result as AnyhowResult;
12use rust_i18n::t;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use crate::app::types::SearchState;
17use crate::app::warning_domains::WarningDomain;
18use crate::model::event::{BufferId, Event, LeafId};
19use crate::state::EditorState;
20use crate::view::prompt::PromptType;
21use crate::view::split::SplitViewState;
22
23use super::help;
24use super::Editor;
25
26impl Editor {
27    /// Get the preferred split for opening a file.
28    /// If the active split has no label, use it (normal case).
29    /// Otherwise find an unlabeled leaf so files don't open in labeled splits (e.g., sidebars).
30    fn preferred_split_for_file(&self) -> LeafId {
31        let active = self.split_manager.active_split();
32        if self.split_manager.get_label(active.into()).is_none() {
33            return active;
34        }
35        self.split_manager.find_unlabeled_leaf().unwrap_or(active)
36    }
37
38    /// Open a file and return its buffer ID
39    ///
40    /// If the file doesn't exist, creates an unsaved buffer with that filename.
41    /// Saving the buffer will create the file.
42    pub fn open_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
43        // Check whether the active buffer had a file path before loading.
44        // If it didn't, open_file_no_focus may replace the empty initial buffer
45        // in-place (same buffer ID, new content), and we need to notify plugins.
46        let active_had_path = self
47            .buffers
48            .get(&self.active_buffer())
49            .and_then(|s| s.buffer.file_path())
50            .is_some();
51
52        let buffer_id = self.open_file_no_focus(path)?;
53
54        // Check if this was an already-open buffer or a new one
55        // For already-open buffers, just switch to them
56        // For new buffers, record position history before switching
57        let is_new_buffer = self.active_buffer() != buffer_id;
58
59        if is_new_buffer {
60            // Save current position before switching to new buffer
61            self.position_history.commit_pending_movement();
62
63            // Explicitly record current position before switching
64            let cursors = self.active_cursors();
65            let position = cursors.primary().position;
66            let anchor = cursors.primary().anchor;
67            self.position_history
68                .record_movement(self.active_buffer(), position, anchor);
69            self.position_history.commit_pending_movement();
70        }
71
72        self.set_active_buffer(buffer_id);
73
74        // If the initial empty buffer was replaced in-place with file content,
75        // set_active_buffer is a no-op (same buffer ID). Fire buffer_activated
76        // explicitly so plugins see the newly loaded file.
77        // Skip this when re-opening an already-active file (active_had_path),
78        // as nothing changed and the extra hook would cause spurious refreshes
79        // in plugins like the diagnostics panel.
80        if !is_new_buffer && !active_had_path {
81            #[cfg(feature = "plugins")]
82            self.update_plugin_state_snapshot();
83
84            self.plugin_manager.run_hook(
85                "buffer_activated",
86                crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
87            );
88        }
89
90        // Use display_name from metadata for relative path display
91        let display_name = self
92            .buffer_metadata
93            .get(&buffer_id)
94            .map(|m| m.display_name.clone())
95            .unwrap_or_else(|| path.display().to_string());
96
97        // Check if buffer is binary for status message
98        let is_binary = self
99            .buffers
100            .get(&buffer_id)
101            .map(|s| s.buffer.is_binary())
102            .unwrap_or(false);
103
104        // Show appropriate status message for binary vs regular files
105        if is_binary {
106            self.status_message = Some(t!("buffer.opened_binary", name = display_name).to_string());
107        } else {
108            self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
109        }
110
111        Ok(buffer_id)
112    }
113
114    /// Open a file without switching focus to it
115    ///
116    /// Creates a new buffer for the file (or returns existing buffer ID if already open)
117    /// but does not change the active buffer. Useful for opening files in background tabs.
118    ///
119    /// If the file doesn't exist, creates an unsaved buffer with that filename.
120    pub fn open_file_no_focus(&mut self, path: &Path) -> anyhow::Result<BufferId> {
121        // Resolve relative paths against appropriate base directory
122        // For remote mode, use the remote home directory; for local, use working_dir
123        let base_dir = if self.filesystem.remote_connection_info().is_some() {
124            self.filesystem
125                .home_dir()
126                .unwrap_or_else(|_| self.working_dir.clone())
127        } else {
128            self.working_dir.clone()
129        };
130
131        let resolved_path = if path.is_relative() {
132            base_dir.join(path)
133        } else {
134            path.to_path_buf()
135        };
136
137        // Determine if we're opening a non-existent file (for creating new files)
138        // Use filesystem trait method to support remote files
139        let file_exists = self.filesystem.exists(&resolved_path);
140
141        // Save the user-visible (non-canonicalized) path for language detection.
142        // Glob patterns in language config should match the path as the user sees it,
143        // not the canonical path (e.g., on macOS /var -> /private/var symlinks).
144        let display_path = resolved_path.clone();
145
146        // Canonicalize the path to resolve symlinks and normalize path components
147        // This ensures consistent path representation throughout the editor
148        // For non-existent files, we need to canonicalize the parent directory and append the filename
149        let canonical_path = if file_exists {
150            self.filesystem
151                .canonicalize(&resolved_path)
152                .unwrap_or_else(|_| resolved_path.clone())
153        } else {
154            // For non-existent files, canonicalize parent dir and append filename
155            if let Some(parent) = resolved_path.parent() {
156                let canonical_parent = if parent.as_os_str().is_empty() {
157                    // No parent means just a filename, use base dir
158                    base_dir.clone()
159                } else {
160                    self.filesystem
161                        .canonicalize(parent)
162                        .unwrap_or_else(|_| parent.to_path_buf())
163                };
164                if let Some(filename) = resolved_path.file_name() {
165                    canonical_parent.join(filename)
166                } else {
167                    resolved_path
168                }
169            } else {
170                resolved_path
171            }
172        };
173        let path = canonical_path.as_path();
174
175        // Check if the path is a directory (after following symlinks via canonicalize)
176        // Directories cannot be opened as files in the editor
177        // Use filesystem trait method to support remote files
178        if self.filesystem.is_dir(path).unwrap_or(false) {
179            anyhow::bail!(t!("buffer.cannot_open_directory"));
180        }
181
182        // Check if file is already open - return existing buffer without switching
183        let already_open = self
184            .buffers
185            .iter()
186            .find(|(_, state)| state.buffer.file_path() == Some(path))
187            .map(|(id, _)| *id);
188
189        if let Some(id) = already_open {
190            return Ok(id);
191        }
192
193        // If the current buffer is empty and unmodified, replace it instead of creating a new one
194        // Note: Don't replace composite buffers (they appear empty but are special views)
195        let replace_current = {
196            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
197            !current_state.is_composite_buffer
198                && current_state.buffer.is_empty()
199                && !current_state.buffer.is_modified()
200                && current_state.buffer.file_path().is_none()
201        };
202
203        let buffer_id = if replace_current {
204            // Reuse the current empty buffer
205            self.active_buffer()
206        } else {
207            // Create new buffer for this file
208            let id = BufferId(self.next_buffer_id);
209            self.next_buffer_id += 1;
210            id
211        };
212
213        // Create the editor state - either load from file or create empty buffer
214        tracing::info!(
215            "[SYNTAX DEBUG] open_file_no_focus: path={:?}, extension={:?}, registry_syntaxes={}, user_extensions={:?}",
216            path,
217            path.extension(),
218            self.grammar_registry.available_syntaxes().len(),
219            self.grammar_registry.user_extensions_debug()
220        );
221        let mut state = if file_exists {
222            // Load from canonical path (for I/O and dedup), detect language from
223            // display path (for glob pattern matching against user-visible names).
224            let buffer = crate::model::buffer::Buffer::load_from_file(
225                &canonical_path,
226                self.config.editor.large_file_threshold_bytes as usize,
227                Arc::clone(&self.filesystem),
228            )?;
229            let detected = crate::primitives::detected_language::DetectedLanguage::from_path(
230                &display_path,
231                &self.grammar_registry,
232                &self.config.languages,
233            );
234            EditorState::from_buffer_with_language(buffer, detected)
235        } else {
236            // File doesn't exist - create empty buffer with the file path set
237            EditorState::new_with_path(
238                self.config.editor.large_file_threshold_bytes as usize,
239                Arc::clone(&self.filesystem),
240                path.to_path_buf(),
241            )
242        };
243        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
244
245        // Check if the buffer contains binary content
246        let is_binary = state.buffer.is_binary();
247        if is_binary {
248            // Make binary buffers read-only
249            state.editing_disabled = true;
250            tracing::info!("Detected binary file: {}", path.display());
251        }
252
253        // Set whitespace visibility, use_tabs, and tab_size based on language config
254        // with fallback to global editor config for tab_size
255        // Use the buffer's stored language (already set by from_file_with_languages)
256        let mut whitespace =
257            crate::config::WhitespaceVisibility::from_editor_config(&self.config.editor);
258        state.buffer_settings.auto_close = self.config.editor.auto_close;
259        state.buffer_settings.auto_surround = self.config.editor.auto_surround;
260        if let Some(lang_config) = self.config.languages.get(&state.language) {
261            whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
262            state.buffer_settings.use_tabs = lang_config.use_tabs;
263            // Use language-specific tab_size if set, otherwise fall back to global
264            state.buffer_settings.tab_size =
265                lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
266            // Auto close: language override (only if globally enabled)
267            if state.buffer_settings.auto_close {
268                if let Some(lang_auto_close) = lang_config.auto_close {
269                    state.buffer_settings.auto_close = lang_auto_close;
270                }
271            }
272            // Auto surround: language override (only if globally enabled)
273            if state.buffer_settings.auto_surround {
274                if let Some(lang_auto_surround) = lang_config.auto_surround {
275                    state.buffer_settings.auto_surround = lang_auto_surround;
276                }
277            }
278        } else {
279            state.buffer_settings.tab_size = self.config.editor.tab_size;
280        }
281        state.buffer_settings.whitespace = whitespace;
282
283        // Apply line_numbers default from config
284        state
285            .margins
286            .configure_for_line_numbers(self.config.editor.line_numbers);
287
288        self.buffers.insert(buffer_id, state);
289        self.event_logs
290            .insert(buffer_id, crate::model::event::EventLog::new());
291
292        // Create metadata for this buffer
293        let mut metadata =
294            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
295
296        // Mark binary files in metadata and disable LSP
297        if is_binary {
298            metadata.binary = true;
299            metadata.read_only = true;
300            metadata.disable_lsp(t!("buffer.binary_file").to_string());
301        }
302
303        // Check if the file is read-only on disk (filesystem permissions)
304        if file_exists && !metadata.read_only {
305            if let Ok(file_meta) = self.filesystem.metadata(path) {
306                if file_meta.is_readonly {
307                    metadata.read_only = true;
308                }
309            }
310        }
311
312        // Mark read-only files (library, binary, or filesystem-readonly) as editing-disabled
313        if metadata.read_only {
314            if let Some(state) = self.buffers.get_mut(&buffer_id) {
315                state.editing_disabled = true;
316            }
317        }
318
319        // Notify LSP about the newly opened file (skip for binary files)
320        if !is_binary {
321            self.notify_lsp_file_opened(path, buffer_id, &mut metadata);
322        }
323
324        // Store metadata for this buffer
325        self.buffer_metadata.insert(buffer_id, metadata);
326
327        // Add buffer to the preferred split's tabs (but don't switch to it)
328        // Uses preferred_split_for_file() to avoid opening in labeled splits (e.g., sidebars)
329        let target_split = self.preferred_split_for_file();
330        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
331            view_state.add_buffer(buffer_id);
332            // Initialize per-buffer view state for the new buffer with config defaults
333            let buf_state = view_state.ensure_buffer_state(buffer_id);
334            buf_state.apply_config_defaults(
335                self.config.editor.line_numbers,
336                self.config.editor.line_wrap,
337                self.config.editor.wrap_indent,
338                self.config.editor.rulers.clone(),
339            );
340        }
341
342        // Restore global file state (scroll/cursor position) if available
343        // This persists file positions across projects and editor instances
344        self.restore_global_file_state(buffer_id, path, target_split);
345
346        // Emit control event
347        self.emit_event(
348            crate::model::control_event::events::FILE_OPENED.name,
349            serde_json::json!({
350                "path": path.display().to_string(),
351                "buffer_id": buffer_id.0
352            }),
353        );
354
355        // Track file for auto-revert and conflict detection
356        self.watch_file(path);
357
358        // Fire AfterFileOpen hook for plugins
359        self.plugin_manager.run_hook(
360            "after_file_open",
361            crate::services::plugins::hooks::HookArgs::AfterFileOpen {
362                buffer_id,
363                path: path.to_path_buf(),
364            },
365        );
366
367        Ok(buffer_id)
368    }
369
370    /// Open a local file (always uses local filesystem, not remote)
371    ///
372    /// This is used for opening local files like log files when in remote mode.
373    /// Unlike `open_file`, this always uses the local filesystem even when
374    /// the editor is connected to a remote server.
375    pub fn open_local_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
376        // Resolve relative paths against working_dir
377        let resolved_path = if path.is_relative() {
378            self.working_dir.join(path)
379        } else {
380            path.to_path_buf()
381        };
382
383        // Save user-visible path for language detection before canonicalizing
384        let display_path = resolved_path.clone();
385
386        // Canonicalize the path
387        let canonical_path = resolved_path
388            .canonicalize()
389            .unwrap_or_else(|_| resolved_path.clone());
390        let path = canonical_path.as_path();
391
392        // Check if already open
393        let already_open = self
394            .buffers
395            .iter()
396            .find(|(_, state)| state.buffer.file_path() == Some(path))
397            .map(|(id, _)| *id);
398
399        if let Some(id) = already_open {
400            self.set_active_buffer(id);
401            return Ok(id);
402        }
403
404        // Create new buffer
405        let buffer_id = BufferId(self.next_buffer_id);
406        self.next_buffer_id += 1;
407
408        // Load from canonical path (for I/O and dedup), detect language from
409        // display path (for glob pattern matching against user-visible names).
410        let buffer = crate::model::buffer::Buffer::load_from_file(
411            &canonical_path,
412            self.config.editor.large_file_threshold_bytes as usize,
413            Arc::clone(&self.local_filesystem),
414        )?;
415        let detected = crate::primitives::detected_language::DetectedLanguage::from_path(
416            &display_path,
417            &self.grammar_registry,
418            &self.config.languages,
419        );
420        let state = EditorState::from_buffer_with_language(buffer, detected);
421
422        self.buffers.insert(buffer_id, state);
423        self.event_logs
424            .insert(buffer_id, crate::model::event::EventLog::new());
425
426        // Create metadata
427        let metadata =
428            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
429        self.buffer_metadata.insert(buffer_id, metadata);
430
431        // Add to preferred split's tabs (avoids labeled splits like sidebars)
432        let target_split = self.preferred_split_for_file();
433        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
434            view_state.add_buffer(buffer_id);
435            let buf_state = view_state.ensure_buffer_state(buffer_id);
436            buf_state.apply_config_defaults(
437                self.config.editor.line_numbers,
438                self.config.editor.line_wrap,
439                self.config.editor.wrap_indent,
440                self.config.editor.rulers.clone(),
441            );
442        }
443
444        self.set_active_buffer(buffer_id);
445
446        let display_name = path.display().to_string();
447        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
448
449        Ok(buffer_id)
450    }
451
452    /// Open a file with a specific encoding (no auto-detection).
453    ///
454    /// Used when the user disables auto-detection in the file browser
455    /// and selects a specific encoding to use.
456    pub fn open_file_with_encoding(
457        &mut self,
458        path: &Path,
459        encoding: crate::model::buffer::Encoding,
460    ) -> anyhow::Result<BufferId> {
461        // Use the same base directory logic as open_file
462        let base_dir = self.working_dir.clone();
463
464        let resolved_path = if path.is_relative() {
465            base_dir.join(path)
466        } else {
467            path.to_path_buf()
468        };
469
470        // Save user-visible path for language detection before canonicalizing
471        let display_path = resolved_path.clone();
472
473        // Canonicalize the path
474        let canonical_path = self
475            .filesystem
476            .canonicalize(&resolved_path)
477            .unwrap_or_else(|_| resolved_path.clone());
478        let path = canonical_path.as_path();
479
480        // Check if already open
481        let already_open = self
482            .buffers
483            .iter()
484            .find(|(_, state)| state.buffer.file_path() == Some(path))
485            .map(|(id, _)| *id);
486
487        if let Some(id) = already_open {
488            // File is already open - update its encoding and reload
489            if let Some(state) = self.buffers.get_mut(&id) {
490                state.buffer.set_encoding(encoding);
491            }
492            self.set_active_buffer(id);
493            return Ok(id);
494        }
495
496        // Create new buffer with specified encoding
497        let buffer_id = BufferId(self.next_buffer_id);
498        self.next_buffer_id += 1;
499
500        // Load buffer with the specified encoding (use canonical path for I/O)
501        let buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
502            path,
503            encoding,
504            Arc::clone(&self.filesystem),
505            crate::model::buffer::BufferConfig {
506                estimated_line_length: self.config.editor.estimated_line_length,
507            },
508        )?;
509        // Create editor state with the buffer
510        // Use display_path for language detection (glob patterns match user-visible paths)
511        let detected = crate::primitives::detected_language::DetectedLanguage::from_path(
512            &display_path,
513            &self.grammar_registry,
514            &self.config.languages,
515        );
516
517        let mut state = EditorState::from_buffer_with_language(buffer, detected);
518
519        state
520            .margins
521            .configure_for_line_numbers(self.config.editor.line_numbers);
522
523        self.buffers.insert(buffer_id, state);
524        self.event_logs
525            .insert(buffer_id, crate::model::event::EventLog::new());
526
527        let metadata =
528            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
529        self.buffer_metadata.insert(buffer_id, metadata);
530
531        // Add to preferred split's tabs (avoids labeled splits like sidebars)
532        let target_split = self.preferred_split_for_file();
533        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
534            view_state.add_buffer(buffer_id);
535            let buf_state = view_state.ensure_buffer_state(buffer_id);
536            buf_state.apply_config_defaults(
537                self.config.editor.line_numbers,
538                self.config.editor.line_wrap,
539                self.config.editor.wrap_indent,
540                self.config.editor.rulers.clone(),
541            );
542        }
543
544        self.set_active_buffer(buffer_id);
545
546        Ok(buffer_id)
547    }
548
549    /// Reload the current file with a specific encoding.
550    ///
551    /// Requires the buffer to have no unsaved modifications.
552    pub fn reload_with_encoding(
553        &mut self,
554        encoding: crate::model::buffer::Encoding,
555    ) -> anyhow::Result<()> {
556        let buffer_id = self.active_buffer();
557
558        // Get the file path
559        let path = self
560            .buffers
561            .get(&buffer_id)
562            .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()))
563            .ok_or_else(|| anyhow::anyhow!("Buffer has no file path"))?;
564
565        // Check for unsaved modifications
566        if let Some(state) = self.buffers.get(&buffer_id) {
567            if state.buffer.is_modified() {
568                anyhow::bail!("Cannot reload: buffer has unsaved modifications");
569            }
570        }
571
572        // Reload the buffer with the new encoding
573        let new_buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
574            &path,
575            encoding,
576            Arc::clone(&self.filesystem),
577            crate::model::buffer::BufferConfig {
578                estimated_line_length: self.config.editor.estimated_line_length,
579            },
580        )?;
581
582        // Update the buffer in the editor state
583        if let Some(state) = self.buffers.get_mut(&buffer_id) {
584            state.buffer = new_buffer;
585            // Invalidate highlighting
586            state.highlighter.invalidate_all();
587        }
588
589        // Reset cursor to start in the split view state
590        let split_id = self.split_manager.active_split();
591        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
592            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
593                buf_state.cursors = crate::model::cursor::Cursors::new();
594            }
595        }
596
597        Ok(())
598    }
599
600    /// Open a large file with confirmed full loading for non-resynchronizable encoding.
601    ///
602    /// Called after user confirms they want to load a large file with an encoding like
603    /// GB18030, GBK, Shift-JIS, or EUC-KR that requires loading the entire file into memory.
604    pub fn open_file_large_encoding_confirmed(&mut self, path: &Path) -> anyhow::Result<BufferId> {
605        // Use the same base directory logic as open_file
606        let base_dir = self.working_dir.clone();
607
608        let resolved_path = if path.is_relative() {
609            base_dir.join(path)
610        } else {
611            path.to_path_buf()
612        };
613
614        // Save user-visible path for language detection before canonicalizing
615        let display_path = resolved_path.clone();
616
617        // Canonicalize the path
618        let canonical_path = self
619            .filesystem
620            .canonicalize(&resolved_path)
621            .unwrap_or_else(|_| resolved_path.clone());
622        let path = canonical_path.as_path();
623
624        // Check if already open
625        let already_open = self
626            .buffers
627            .iter()
628            .find(|(_, state)| state.buffer.file_path() == Some(path))
629            .map(|(id, _)| *id);
630
631        if let Some(id) = already_open {
632            self.set_active_buffer(id);
633            return Ok(id);
634        }
635
636        // Create new buffer with forced full loading
637        let buffer_id = BufferId(self.next_buffer_id);
638        self.next_buffer_id += 1;
639
640        // Load buffer with forced full loading (bypasses the large file encoding check)
641        let buffer = crate::model::buffer::Buffer::load_large_file_confirmed(
642            path,
643            Arc::clone(&self.filesystem),
644        )?;
645        // Create editor state with the buffer
646        // Use display_path for language detection (glob patterns match user-visible paths)
647        let detected = crate::primitives::detected_language::DetectedLanguage::from_path(
648            &display_path,
649            &self.grammar_registry,
650            &self.config.languages,
651        );
652
653        let mut state = EditorState::from_buffer_with_language(buffer, detected);
654
655        state
656            .margins
657            .configure_for_line_numbers(self.config.editor.line_numbers);
658
659        self.buffers.insert(buffer_id, state);
660        self.event_logs
661            .insert(buffer_id, crate::model::event::EventLog::new());
662
663        let metadata =
664            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
665        self.buffer_metadata.insert(buffer_id, metadata);
666
667        // Add to preferred split's tabs (avoids labeled splits like sidebars)
668        let target_split = self.preferred_split_for_file();
669        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
670            view_state.add_buffer(buffer_id);
671            let buf_state = view_state.ensure_buffer_state(buffer_id);
672            buf_state.apply_config_defaults(
673                self.config.editor.line_numbers,
674                self.config.editor.line_wrap,
675                self.config.editor.wrap_indent,
676                self.config.editor.rulers.clone(),
677            );
678        }
679
680        self.set_active_buffer(buffer_id);
681
682        // Use display_name from metadata for relative path display
683        let display_name = self
684            .buffer_metadata
685            .get(&buffer_id)
686            .map(|m| m.display_name.clone())
687            .unwrap_or_else(|| path.display().to_string());
688
689        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
690
691        Ok(buffer_id)
692    }
693
694    /// Restore global file state (cursor and scroll position) for a newly opened file
695    ///
696    /// This looks up the file's saved state from the global file states store
697    /// and applies it to both the EditorState (cursor) and SplitViewState (viewport).
698    fn restore_global_file_state(&mut self, buffer_id: BufferId, path: &Path, split_id: LeafId) {
699        use crate::workspace::PersistedFileWorkspace;
700
701        // Load the per-file state for this path (lazy load from disk)
702        let file_state = match PersistedFileWorkspace::load(path) {
703            Some(state) => state,
704            None => return, // No saved state for this file
705        };
706
707        // Get the buffer to validate positions
708        let max_pos = match self.buffers.get(&buffer_id) {
709            Some(buffer) => buffer.buffer.len(),
710            None => return,
711        };
712
713        // Apply cursor position and viewport (scroll) state to SplitViewState
714        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
715            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
716                let cursor_pos = file_state.cursor.position.min(max_pos);
717                buf_state.cursors.primary_mut().position = cursor_pos;
718                buf_state.cursors.primary_mut().anchor =
719                    file_state.cursor.anchor.map(|a| a.min(max_pos));
720            }
721            view_state.viewport.top_byte = file_state.scroll.top_byte;
722            view_state.viewport.left_column = file_state.scroll.left_column;
723        }
724    }
725
726    /// Save file state when a buffer is closed (for per-file session persistence)
727    fn save_file_state_on_close(&self, buffer_id: BufferId) {
728        use crate::workspace::{
729            PersistedFileWorkspace, SerializedCursor, SerializedFileState, SerializedScroll,
730        };
731
732        // Get the file path for this buffer
733        let abs_path = match self.buffer_metadata.get(&buffer_id) {
734            Some(metadata) => match metadata.file_path() {
735                Some(path) => path.to_path_buf(),
736                None => return, // Not a file buffer
737            },
738            None => return,
739        };
740
741        // Find a split that has this buffer open to get the view state
742        let view_state = self
743            .split_view_states
744            .values()
745            .find(|vs| vs.has_buffer(buffer_id));
746
747        let view_state = match view_state {
748            Some(vs) => vs,
749            None => return, // No split has this buffer
750        };
751
752        // Get the per-buffer view state (not necessarily the active buffer in this split)
753        let buf_state = match view_state.keyed_states.get(&buffer_id) {
754            Some(bs) => bs,
755            None => return,
756        };
757
758        // Capture the current state
759        let primary_cursor = buf_state.cursors.primary();
760        let file_state = SerializedFileState {
761            cursor: SerializedCursor {
762                position: primary_cursor.position,
763                anchor: primary_cursor.anchor,
764                sticky_column: primary_cursor.sticky_column,
765            },
766            additional_cursors: buf_state
767                .cursors
768                .iter()
769                .skip(1)
770                .map(|(_, cursor)| SerializedCursor {
771                    position: cursor.position,
772                    anchor: cursor.anchor,
773                    sticky_column: cursor.sticky_column,
774                })
775                .collect(),
776            scroll: SerializedScroll {
777                top_byte: buf_state.viewport.top_byte,
778                top_view_line_offset: buf_state.viewport.top_view_line_offset,
779                left_column: buf_state.viewport.left_column,
780            },
781            view_mode: Default::default(),
782            compose_width: None,
783            plugin_state: std::collections::HashMap::new(),
784            folds: Vec::new(),
785        };
786
787        // Save to disk
788        PersistedFileWorkspace::save(&abs_path, file_state);
789        tracing::debug!("Saved file state on close for {:?}", abs_path);
790    }
791
792    /// Navigate to a specific line and column in the active buffer.
793    ///
794    /// Line and column are 1-indexed (matching typical editor conventions).
795    /// If the line is out of bounds, navigates to the last line.
796    /// If the column is out of bounds, navigates to the end of the line.
797    pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
798        if line == 0 {
799            return; // Line numbers are 1-indexed
800        }
801
802        let buffer_id = self.active_buffer();
803
804        // Read cursor state from split view state
805        let cursors = self.active_cursors();
806        let cursor_id = cursors.primary_id();
807        let old_position = cursors.primary().position;
808        let old_anchor = cursors.primary().anchor;
809        let old_sticky_column = cursors.primary().sticky_column;
810
811        if let Some(state) = self.buffers.get(&buffer_id) {
812            let has_line_index = state.buffer.line_count().is_some();
813            let has_line_scan = state.buffer.has_line_feed_scan();
814            let buffer_len = state.buffer.len();
815
816            // Convert 1-indexed line to 0-indexed
817            let target_line = line.saturating_sub(1);
818            // Column is also 1-indexed, convert to 0-indexed
819            let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
820
821            // Track the known exact line number for scanned large files,
822            // since offset_to_position may not be able to reverse-resolve it accurately.
823            let mut known_line: Option<usize> = None;
824
825            let position = if has_line_scan && has_line_index {
826                // Scanned large file: use tree metadata to find exact line offset
827                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
828                let actual_line = target_line.min(max_line);
829                known_line = Some(actual_line);
830                // Need mutable access to potentially read chunk data from disk
831                if let Some(state) = self.buffers.get_mut(&buffer_id) {
832                    state
833                        .buffer
834                        .resolve_line_byte_offset(actual_line)
835                        .map(|offset| (offset + target_col).min(buffer_len))
836                        .unwrap_or(0)
837                } else {
838                    0
839                }
840            } else {
841                // Small file with full line starts or no line index:
842                // use exact line position
843                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
844                let actual_line = target_line.min(max_line);
845                state.buffer.line_col_to_position(actual_line, target_col)
846            };
847
848            let event = Event::MoveCursor {
849                cursor_id,
850                old_position,
851                new_position: position,
852                old_anchor,
853                new_anchor: None,
854                old_sticky_column,
855                new_sticky_column: target_col,
856            };
857
858            let split_id = self.split_manager.active_split();
859            let state = self.buffers.get_mut(&buffer_id).unwrap();
860            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
861            state.apply(&mut view_state.cursors, &event);
862
863            // For scanned large files, override the line number with the known exact value
864            // since offset_to_position may fall back to proportional estimation.
865            if let Some(line) = known_line {
866                state.primary_cursor_line_number = crate::model::buffer::LineNumber::Absolute(line);
867            }
868        }
869    }
870
871    /// Select a range in the active buffer. Lines/columns are 1-indexed.
872    /// The cursor moves to the end of the range and the anchor is set to the
873    /// start, producing a visual selection.
874    pub fn select_range(
875        &mut self,
876        start_line: usize,
877        start_col: Option<usize>,
878        end_line: usize,
879        end_col: Option<usize>,
880    ) {
881        if start_line == 0 || end_line == 0 {
882            return;
883        }
884
885        let buffer_id = self.active_buffer();
886
887        let cursors = self.active_cursors();
888        let cursor_id = cursors.primary_id();
889        let old_position = cursors.primary().position;
890        let old_anchor = cursors.primary().anchor;
891        let old_sticky_column = cursors.primary().sticky_column;
892
893        if let Some(state) = self.buffers.get(&buffer_id) {
894            let buffer_len = state.buffer.len();
895
896            // Convert 1-indexed to 0-indexed
897            let start_line_0 = start_line.saturating_sub(1);
898            let start_col_0 = start_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
899            let end_line_0 = end_line.saturating_sub(1);
900            let end_col_0 = end_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
901
902            let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
903
904            let start_pos = state
905                .buffer
906                .line_col_to_position(start_line_0.min(max_line), start_col_0)
907                .min(buffer_len);
908            let end_pos = state
909                .buffer
910                .line_col_to_position(end_line_0.min(max_line), end_col_0)
911                .min(buffer_len);
912
913            let event = Event::MoveCursor {
914                cursor_id,
915                old_position,
916                new_position: end_pos,
917                old_anchor,
918                new_anchor: Some(start_pos),
919                old_sticky_column,
920                new_sticky_column: end_col_0,
921            };
922
923            let split_id = self.split_manager.active_split();
924            let state = self.buffers.get_mut(&buffer_id).unwrap();
925            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
926            state.apply(&mut view_state.cursors, &event);
927        }
928    }
929
930    /// Go to an exact byte offset in the buffer (used in byte-offset mode for large files)
931    pub fn goto_byte_offset(&mut self, offset: usize) {
932        let buffer_id = self.active_buffer();
933
934        let cursors = self.active_cursors();
935        let cursor_id = cursors.primary_id();
936        let old_position = cursors.primary().position;
937        let old_anchor = cursors.primary().anchor;
938        let old_sticky_column = cursors.primary().sticky_column;
939
940        if let Some(state) = self.buffers.get(&buffer_id) {
941            let buffer_len = state.buffer.len();
942            let position = offset.min(buffer_len);
943
944            let event = Event::MoveCursor {
945                cursor_id,
946                old_position,
947                new_position: position,
948                old_anchor,
949                new_anchor: None,
950                old_sticky_column,
951                new_sticky_column: 0,
952            };
953
954            let split_id = self.split_manager.active_split();
955            let state = self.buffers.get_mut(&buffer_id).unwrap();
956            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
957            state.apply(&mut view_state.cursors, &event);
958        }
959    }
960
961    /// Create a new empty buffer
962    pub fn new_buffer(&mut self) -> BufferId {
963        // Save current position before switching to new buffer
964        self.position_history.commit_pending_movement();
965
966        // Explicitly record current position before switching
967        let cursors = self.active_cursors();
968        let position = cursors.primary().position;
969        let anchor = cursors.primary().anchor;
970        self.position_history
971            .record_movement(self.active_buffer(), position, anchor);
972        self.position_history.commit_pending_movement();
973
974        let buffer_id = BufferId(self.next_buffer_id);
975        self.next_buffer_id += 1;
976
977        let mut state = EditorState::new(
978            self.terminal_width,
979            self.terminal_height,
980            self.config.editor.large_file_threshold_bytes as usize,
981            Arc::clone(&self.filesystem),
982        );
983        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
984        state
985            .margins
986            .configure_for_line_numbers(self.config.editor.line_numbers);
987        // Set default line ending for new buffers from config
988        state
989            .buffer
990            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
991        self.buffers.insert(buffer_id, state);
992        self.event_logs
993            .insert(buffer_id, crate::model::event::EventLog::new());
994        self.buffer_metadata
995            .insert(buffer_id, crate::app::types::BufferMetadata::new());
996
997        self.set_active_buffer(buffer_id);
998
999        // Initialize per-buffer view state with config defaults.
1000        // Must happen AFTER set_active_buffer, because switch_buffer creates
1001        // the new BufferViewState with defaults (show_line_numbers=true).
1002        let active_split = self.split_manager.active_split();
1003        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1004            view_state.apply_config_defaults(
1005                self.config.editor.line_numbers,
1006                self.config.editor.line_wrap,
1007                self.config.editor.wrap_indent,
1008                self.config.editor.rulers.clone(),
1009            );
1010        }
1011
1012        self.status_message = Some(t!("buffer.new").to_string());
1013
1014        buffer_id
1015    }
1016
1017    /// Create a new buffer from stdin content stored in a temp file
1018    ///
1019    /// Uses lazy chunk loading for efficient handling of large stdin inputs.
1020    /// The buffer is unnamed (no file path for save) - saving will prompt for a filename.
1021    /// The temp file path is preserved internally for lazy loading to work.
1022    ///
1023    /// # Arguments
1024    /// * `temp_path` - Path to temp file where stdin content is being written
1025    /// * `thread_handle` - Optional handle to background thread streaming stdin to temp file
1026    pub fn open_stdin_buffer(
1027        &mut self,
1028        temp_path: &Path,
1029        thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
1030    ) -> AnyhowResult<BufferId> {
1031        // Save current position before switching to new buffer
1032        self.position_history.commit_pending_movement();
1033
1034        // Explicitly record current position before switching
1035        let cursors = self.active_cursors();
1036        let position = cursors.primary().position;
1037        let anchor = cursors.primary().anchor;
1038        self.position_history
1039            .record_movement(self.active_buffer(), position, anchor);
1040        self.position_history.commit_pending_movement();
1041
1042        // If the current buffer is empty and unmodified, replace it instead of creating a new one
1043        // Note: Don't replace composite buffers (they appear empty but are special views)
1044        let replace_current = {
1045            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
1046            !current_state.is_composite_buffer
1047                && current_state.buffer.is_empty()
1048                && !current_state.buffer.is_modified()
1049                && current_state.buffer.file_path().is_none()
1050        };
1051
1052        let buffer_id = if replace_current {
1053            // Reuse the current empty buffer
1054            self.active_buffer()
1055        } else {
1056            // Create new buffer ID
1057            let id = BufferId(self.next_buffer_id);
1058            self.next_buffer_id += 1;
1059            id
1060        };
1061
1062        // Get file size for status message before loading
1063        let file_size = self.filesystem.metadata(temp_path)?.size as usize;
1064
1065        // Load from temp file using EditorState::from_file_with_languages
1066        // This enables lazy chunk loading for large inputs (>100MB by default)
1067        let mut state = EditorState::from_file_with_languages(
1068            temp_path,
1069            self.terminal_width,
1070            self.terminal_height,
1071            self.config.editor.large_file_threshold_bytes as usize,
1072            &self.grammar_registry,
1073            &self.config.languages,
1074            Arc::clone(&self.filesystem),
1075        )?;
1076
1077        // Clear the file path so the buffer is "unnamed" for save purposes
1078        // The Unloaded chunks still reference the temp file for lazy loading
1079        state.buffer.clear_file_path();
1080        // Clear modified flag - content is "fresh" from stdin (vim behavior)
1081        state.buffer.clear_modified();
1082
1083        // Set tab size, auto_close, and auto_surround from config
1084        state.buffer_settings.tab_size = self.config.editor.tab_size;
1085        state.buffer_settings.auto_close = self.config.editor.auto_close;
1086        state.buffer_settings.auto_surround = self.config.editor.auto_surround;
1087
1088        // Apply line_numbers default from config
1089        state
1090            .margins
1091            .configure_for_line_numbers(self.config.editor.line_numbers);
1092
1093        self.buffers.insert(buffer_id, state);
1094        self.event_logs
1095            .insert(buffer_id, crate::model::event::EventLog::new());
1096
1097        // Create metadata for this buffer (no file path)
1098        let metadata =
1099            super::types::BufferMetadata::new_unnamed(t!("stdin.display_name").to_string());
1100        self.buffer_metadata.insert(buffer_id, metadata);
1101
1102        // Add buffer to the active split's tabs
1103        let active_split = self.split_manager.active_split();
1104        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1105            view_state.add_buffer(buffer_id);
1106            let buf_state = view_state.ensure_buffer_state(buffer_id);
1107            buf_state.apply_config_defaults(
1108                self.config.editor.line_numbers,
1109                self.config.editor.line_wrap,
1110                self.config.editor.wrap_indent,
1111                self.config.editor.rulers.clone(),
1112            );
1113        }
1114
1115        self.set_active_buffer(buffer_id);
1116
1117        // Set up stdin streaming state for polling
1118        // If no thread handle, it means data is already complete (testing scenario)
1119        let complete = thread_handle.is_none();
1120        self.stdin_streaming = Some(super::StdinStreamingState {
1121            temp_path: temp_path.to_path_buf(),
1122            buffer_id,
1123            last_known_size: file_size,
1124            complete,
1125            thread_handle,
1126        });
1127
1128        // Status will be updated by poll_stdin_streaming
1129        self.status_message = Some(t!("stdin.streaming").to_string());
1130
1131        Ok(buffer_id)
1132    }
1133
1134    /// Poll stdin streaming state and extend buffer if file grew.
1135    /// Returns true if the status changed (needs render).
1136    pub fn poll_stdin_streaming(&mut self) -> bool {
1137        let Some(ref mut stream_state) = self.stdin_streaming else {
1138            return false;
1139        };
1140
1141        if stream_state.complete {
1142            return false;
1143        }
1144
1145        let mut changed = false;
1146
1147        // Check current file size
1148        let current_size = self
1149            .filesystem
1150            .metadata(&stream_state.temp_path)
1151            .map(|m| m.size as usize)
1152            .unwrap_or(stream_state.last_known_size);
1153
1154        // If file grew, extend the buffer
1155        if current_size > stream_state.last_known_size {
1156            if let Some(editor_state) = self.buffers.get_mut(&stream_state.buffer_id) {
1157                editor_state
1158                    .buffer
1159                    .extend_streaming(&stream_state.temp_path, current_size);
1160            }
1161            stream_state.last_known_size = current_size;
1162
1163            // Update status message with current progress
1164            self.status_message =
1165                Some(t!("stdin.streaming_bytes", bytes = current_size).to_string());
1166            changed = true;
1167        }
1168
1169        // Check if background thread has finished
1170        let thread_finished = stream_state
1171            .thread_handle
1172            .as_ref()
1173            .map(|h| h.is_finished())
1174            .unwrap_or(true);
1175
1176        if thread_finished {
1177            // Take ownership of handle to join it
1178            if let Some(handle) = stream_state.thread_handle.take() {
1179                match handle.join() {
1180                    Ok(Ok(())) => {
1181                        tracing::info!("Stdin streaming completed successfully");
1182                    }
1183                    Ok(Err(e)) => {
1184                        tracing::warn!("Stdin streaming error: {}", e);
1185                        self.status_message =
1186                            Some(t!("stdin.read_error", error = e.to_string()).to_string());
1187                    }
1188                    Err(_) => {
1189                        tracing::warn!("Stdin streaming thread panicked");
1190                        self.status_message = Some(t!("stdin.read_error_panic").to_string());
1191                    }
1192                }
1193            }
1194            self.complete_stdin_streaming();
1195            changed = true;
1196        }
1197
1198        changed
1199    }
1200
1201    /// Mark stdin streaming as complete.
1202    /// Called when the background thread finishes.
1203    pub fn complete_stdin_streaming(&mut self) {
1204        if let Some(ref mut stream_state) = self.stdin_streaming {
1205            stream_state.complete = true;
1206
1207            // Final poll to get any remaining data
1208            let final_size = self
1209                .filesystem
1210                .metadata(&stream_state.temp_path)
1211                .map(|m| m.size as usize)
1212                .unwrap_or(stream_state.last_known_size);
1213
1214            if final_size > stream_state.last_known_size {
1215                if let Some(editor_state) = self.buffers.get_mut(&stream_state.buffer_id) {
1216                    editor_state
1217                        .buffer
1218                        .extend_streaming(&stream_state.temp_path, final_size);
1219                }
1220                stream_state.last_known_size = final_size;
1221            }
1222
1223            self.status_message =
1224                Some(t!("stdin.read_complete", bytes = stream_state.last_known_size).to_string());
1225        }
1226    }
1227
1228    /// Check if stdin streaming is active (not complete).
1229    pub fn is_stdin_streaming(&self) -> bool {
1230        self.stdin_streaming
1231            .as_ref()
1232            .map(|s| !s.complete)
1233            .unwrap_or(false)
1234    }
1235
1236    /// Create a new virtual buffer (not backed by a file)
1237    ///
1238    /// # Arguments
1239    /// * `name` - Display name (e.g., "*Diagnostics*")
1240    /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
1241    /// * `read_only` - Whether the buffer should be read-only
1242    ///
1243    /// # Returns
1244    /// The BufferId of the created virtual buffer
1245    pub fn create_virtual_buffer(
1246        &mut self,
1247        name: String,
1248        mode: String,
1249        read_only: bool,
1250    ) -> BufferId {
1251        let buffer_id = BufferId(self.next_buffer_id);
1252        self.next_buffer_id += 1;
1253
1254        let mut state = EditorState::new(
1255            self.terminal_width,
1256            self.terminal_height,
1257            self.config.editor.large_file_threshold_bytes as usize,
1258            Arc::clone(&self.filesystem),
1259        );
1260        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
1261
1262        // Set syntax highlighting based on buffer name (e.g., "*OURS*.c" will get C highlighting)
1263        state.set_language_from_name(&name, &self.grammar_registry);
1264
1265        // Apply line_numbers default from config
1266        state
1267            .margins
1268            .configure_for_line_numbers(self.config.editor.line_numbers);
1269
1270        self.buffers.insert(buffer_id, state);
1271        self.event_logs
1272            .insert(buffer_id, crate::model::event::EventLog::new());
1273
1274        // Set virtual buffer metadata
1275        let metadata = super::types::BufferMetadata::virtual_buffer(name, mode, read_only);
1276        self.buffer_metadata.insert(buffer_id, metadata);
1277
1278        // Add buffer to the active split's open_buffers (tabs)
1279        let active_split = self.split_manager.active_split();
1280        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1281            view_state.add_buffer(buffer_id);
1282            let buf_state = view_state.ensure_buffer_state(buffer_id);
1283            buf_state.apply_config_defaults(
1284                self.config.editor.line_numbers,
1285                self.config.editor.line_wrap,
1286                self.config.editor.wrap_indent,
1287                self.config.editor.rulers.clone(),
1288            );
1289        } else {
1290            // Create view state if it doesn't exist
1291            let mut view_state =
1292                SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buffer_id);
1293            view_state.apply_config_defaults(
1294                self.config.editor.line_numbers,
1295                self.config.editor.line_wrap,
1296                self.config.editor.wrap_indent,
1297                self.config.editor.rulers.clone(),
1298            );
1299            self.split_view_states.insert(active_split, view_state);
1300        }
1301
1302        buffer_id
1303    }
1304
1305    /// Set the content of a virtual buffer with text properties
1306    ///
1307    /// # Arguments
1308    /// * `buffer_id` - The virtual buffer to update
1309    /// * `entries` - Text entries with embedded properties
1310    pub fn set_virtual_buffer_content(
1311        &mut self,
1312        buffer_id: BufferId,
1313        entries: Vec<crate::primitives::text_property::TextPropertyEntry>,
1314    ) -> Result<(), String> {
1315        // Save current cursor position from split view state to preserve it after content update
1316        let old_cursor_pos = self
1317            .split_view_states
1318            .values()
1319            .find(|vs| vs.has_buffer(buffer_id))
1320            .and_then(|vs| vs.keyed_states.get(&buffer_id))
1321            .map(|bs| bs.cursors.primary().position)
1322            .unwrap_or(0);
1323
1324        let state = self
1325            .buffers
1326            .get_mut(&buffer_id)
1327            .ok_or_else(|| "Buffer not found".to_string())?;
1328
1329        // Build text and properties from entries
1330        let (text, properties, collected_overlays) =
1331            crate::primitives::text_property::TextPropertyManager::from_entries(entries);
1332
1333        // Replace buffer content
1334        // Note: we use buffer.delete_bytes/insert directly (not state.delete_range/insert_text_at)
1335        // which bypasses marker_list adjustment. Clear ALL overlays first so no stale markers
1336        // remain pointing at invalid positions in the new content.
1337        state.overlays.clear(&mut state.marker_list);
1338
1339        let current_len = state.buffer.len();
1340        if current_len > 0 {
1341            state.buffer.delete_bytes(0, current_len);
1342        }
1343        state.buffer.insert(0, &text);
1344
1345        // Clear modified flag since this is virtual buffer content setting, not user edits
1346        state.buffer.clear_modified();
1347
1348        // Set text properties
1349        state.text_properties = properties;
1350
1351        // Create inline overlays for the new content
1352        {
1353            use crate::view::overlay::{Overlay, OverlayFace};
1354            use fresh_core::overlay::OverlayNamespace;
1355
1356            let inline_ns = OverlayNamespace::from_string("_inline".to_string());
1357
1358            for co in collected_overlays {
1359                let face = OverlayFace::from_options(&co.options);
1360                let mut overlay = Overlay::with_namespace(
1361                    &mut state.marker_list,
1362                    co.range,
1363                    face,
1364                    inline_ns.clone(),
1365                );
1366                overlay.extend_to_line_end = co.options.extend_to_line_end;
1367                if let Some(url) = co.options.url {
1368                    overlay.url = Some(url);
1369                }
1370                state.overlays.add(overlay);
1371            }
1372        }
1373
1374        // Preserve cursor position (clamped to new content length and snapped to char boundary)
1375        let new_len = state.buffer.len();
1376        let clamped_pos = old_cursor_pos.min(new_len);
1377        // Ensure cursor is at a valid UTF-8 character boundary (without moving if already valid)
1378        let new_cursor_pos = state.buffer.snap_to_char_boundary(clamped_pos);
1379
1380        // Update cursor in the split view state that has this buffer
1381        for view_state in self.split_view_states.values_mut() {
1382            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
1383                buf_state.cursors.primary_mut().position = new_cursor_pos;
1384                buf_state.cursors.primary_mut().anchor = None;
1385            }
1386        }
1387
1388        Ok(())
1389    }
1390
1391    /// Open the built-in help manual in a read-only buffer
1392    ///
1393    /// If a help manual buffer already exists, switch to it instead of creating a new one.
1394    pub fn open_help_manual(&mut self) {
1395        // Check if help buffer already exists
1396        let existing_buffer = self
1397            .buffer_metadata
1398            .iter()
1399            .find(|(_, m)| m.display_name == help::HELP_MANUAL_BUFFER_NAME)
1400            .map(|(id, _)| *id);
1401
1402        if let Some(buffer_id) = existing_buffer {
1403            // Switch to existing help buffer
1404            self.set_active_buffer(buffer_id);
1405            return;
1406        }
1407
1408        // Create new help buffer with "special" mode (has 'q' to close)
1409        let buffer_id = self.create_virtual_buffer(
1410            help::HELP_MANUAL_BUFFER_NAME.to_string(),
1411            "special".to_string(),
1412            true,
1413        );
1414
1415        // Set the content
1416        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1417            state.buffer.insert(0, help::HELP_MANUAL_CONTENT);
1418            state.buffer.clear_modified();
1419            state.editing_disabled = true;
1420
1421            // Disable line numbers for cleaner display
1422            state.margins.configure_for_line_numbers(false);
1423        }
1424
1425        self.set_active_buffer(buffer_id);
1426    }
1427
1428    /// Open the keyboard shortcuts viewer in a read-only buffer
1429    ///
1430    /// If a keyboard shortcuts buffer already exists, switch to it instead of creating a new one.
1431    /// The shortcuts are dynamically generated from the current keybindings configuration.
1432    pub fn open_keyboard_shortcuts(&mut self) {
1433        // Check if keyboard shortcuts buffer already exists
1434        let existing_buffer = self
1435            .buffer_metadata
1436            .iter()
1437            .find(|(_, m)| m.display_name == help::KEYBOARD_SHORTCUTS_BUFFER_NAME)
1438            .map(|(id, _)| *id);
1439
1440        if let Some(buffer_id) = existing_buffer {
1441            // Switch to existing buffer
1442            self.set_active_buffer(buffer_id);
1443            return;
1444        }
1445
1446        // Get all keybindings
1447        let bindings = self.keybindings.get_all_bindings();
1448
1449        // Format the keybindings as readable text
1450        let mut content = String::from("Keyboard Shortcuts\n");
1451        content.push_str("==================\n\n");
1452        content.push_str("Press 'q' to close this buffer.\n\n");
1453
1454        // Group bindings by context (Normal, Prompt, etc.)
1455        let mut current_context = String::new();
1456        for (key, action) in &bindings {
1457            // Check if action starts with a context prefix like "[Prompt] "
1458            let (context, action_name) = if let Some(bracket_end) = action.find("] ") {
1459                let ctx = &action[1..bracket_end];
1460                let name = &action[bracket_end + 2..];
1461                (ctx.to_string(), name.to_string())
1462            } else {
1463                ("Normal".to_string(), action.clone())
1464            };
1465
1466            // Print context header when it changes
1467            if context != current_context {
1468                if !current_context.is_empty() {
1469                    content.push('\n');
1470                }
1471                content.push_str(&format!("── {} Mode ──\n\n", context));
1472                current_context = context;
1473            }
1474
1475            // Format: "  Ctrl+S          Save"
1476            content.push_str(&format!("  {:20} {}\n", key, action_name));
1477        }
1478
1479        // Create new keyboard shortcuts buffer with "special" mode (has 'q' to close)
1480        let buffer_id = self.create_virtual_buffer(
1481            help::KEYBOARD_SHORTCUTS_BUFFER_NAME.to_string(),
1482            "special".to_string(),
1483            true,
1484        );
1485
1486        // Set the content
1487        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1488            state.buffer.insert(0, &content);
1489            state.buffer.clear_modified();
1490            state.editing_disabled = true;
1491
1492            // Disable line numbers for cleaner display
1493            state.margins.configure_for_line_numbers(false);
1494        }
1495
1496        self.set_active_buffer(buffer_id);
1497    }
1498
1499    /// Show warnings by opening the warning log file directly
1500    ///
1501    /// If there are no warnings, shows a brief status message.
1502    /// Otherwise, opens the warning log file for the user to view.
1503    pub fn show_warnings_popup(&mut self) {
1504        if !self.warning_domains.has_any_warnings() {
1505            self.status_message = Some(t!("warnings.none").to_string());
1506            return;
1507        }
1508
1509        // Open the warning log file directly
1510        self.open_warning_log();
1511    }
1512
1513    /// Show LSP status - opens the warning log file if there are LSP warnings,
1514    /// otherwise shows a brief status message.
1515    pub fn show_lsp_status_popup(&mut self) {
1516        let has_error = self.warning_domains.lsp.level() == crate::app::WarningLevel::Error;
1517
1518        // Use the language from the LSP error state if available, otherwise detect from buffer.
1519        // This ensures clicking the status indicator works regardless of which buffer is focused.
1520        let language = self
1521            .warning_domains
1522            .lsp
1523            .language
1524            .clone()
1525            .unwrap_or_else(|| {
1526                // Use buffer's stored language
1527                self.buffers
1528                    .get(&self.active_buffer())
1529                    .map(|s| s.language.clone())
1530                    .unwrap_or_else(|| "unknown".to_string())
1531            });
1532
1533        tracing::info!(
1534            "show_lsp_status_popup: language={}, has_error={}, has_warnings={}",
1535            language,
1536            has_error,
1537            self.warning_domains.lsp.has_warnings()
1538        );
1539
1540        // Fire the LspStatusClicked hook for plugins
1541        self.plugin_manager.run_hook(
1542            "lsp_status_clicked",
1543            crate::services::plugins::hooks::HookArgs::LspStatusClicked {
1544                language: language.clone(),
1545                has_error,
1546            },
1547        );
1548        tracing::info!("show_lsp_status_popup: hook fired");
1549
1550        if !self.warning_domains.lsp.has_warnings() {
1551            if self.lsp_status.is_empty() {
1552                self.status_message = Some(t!("lsp.no_server_active").to_string());
1553            } else {
1554                self.status_message = Some(t!("lsp.status", status = &self.lsp_status).to_string());
1555            }
1556            return;
1557        }
1558
1559        // If there's an LSP error AND a plugin is handling the status click, don't open the
1560        // warning log which would switch focus and break language detection for subsequent clicks.
1561        // Only suppress if a plugin has registered to handle the hook.
1562        if has_error && self.plugin_manager.has_hook_handlers("lsp_status_clicked") {
1563            tracing::info!(
1564                "show_lsp_status_popup: has_error=true and plugin registered, skipping warning log"
1565            );
1566            return;
1567        }
1568
1569        // Open the warning log file directly (same as warnings popup)
1570        self.open_warning_log();
1571    }
1572
1573    /// Show a transient hover popup with the given message text, positioned below the cursor.
1574    /// Used for file-open messages (e.g. `file.txt:10@"Look at this"`).
1575    pub fn show_file_message_popup(&mut self, message: &str) {
1576        use crate::view::popup::{Popup, PopupPosition};
1577        use ratatui::style::Style;
1578
1579        // Build markdown: message text + blank line + italic hint
1580        let md = format!("{}\n\n*esc to dismiss*", message);
1581        // Size popup width to content: longest line + border padding, clamped to reasonable bounds
1582        let content_width = message.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
1583        let hint_width = 16u16; // "*esc to dismiss*"
1584        let popup_width = (content_width.max(hint_width) + 4).clamp(20, 60);
1585
1586        let mut popup = Popup::markdown(&md, &self.theme, Some(&self.grammar_registry));
1587        popup.transient = false;
1588        popup.position = PopupPosition::BelowCursor;
1589        popup.width = popup_width;
1590        popup.max_height = 15;
1591        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
1592        popup.background_style = Style::default().bg(self.theme.popup_bg);
1593
1594        let buffer_id = self.active_buffer();
1595        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1596            state.popups.show(popup);
1597        }
1598    }
1599
1600    /// Get text properties at the cursor position in the active buffer
1601    pub fn get_text_properties_at_cursor(
1602        &self,
1603    ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
1604        let state = self.buffers.get(&self.active_buffer())?;
1605        let cursor_pos = self.active_cursors().primary().position;
1606        Some(state.text_properties.get_at(cursor_pos))
1607    }
1608
1609    /// Close the given buffer
1610    pub fn close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
1611        // Check for unsaved changes
1612        if let Some(state) = self.buffers.get(&id) {
1613            if state.buffer.is_modified() {
1614                return Err(anyhow::anyhow!("Buffer has unsaved changes"));
1615            }
1616        }
1617        self.close_buffer_internal(id)
1618    }
1619
1620    /// Force close the given buffer without checking for unsaved changes
1621    /// Use this when the user has already confirmed they want to discard changes
1622    pub fn force_close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
1623        self.close_buffer_internal(id)
1624    }
1625
1626    /// Internal helper to close a buffer (shared by close_buffer and force_close_buffer)
1627    fn close_buffer_internal(&mut self, id: BufferId) -> anyhow::Result<()> {
1628        // Complete any --wait tracking for this buffer
1629        if let Some((wait_id, _)) = self.wait_tracking.remove(&id) {
1630            self.completed_waits.push(wait_id);
1631        }
1632
1633        // Save file state before closing (for per-file session persistence)
1634        self.save_file_state_on_close(id);
1635
1636        // If closing a terminal buffer, clean up terminal-related data structures
1637        if let Some(terminal_id) = self.terminal_buffers.remove(&id) {
1638            // Close the terminal process
1639            self.terminal_manager.close(terminal_id);
1640
1641            // Clean up backing/rendering file
1642            let backing_file = self.terminal_backing_files.remove(&terminal_id);
1643            if let Some(ref path) = backing_file {
1644                // Best-effort cleanup of temporary terminal files.
1645                #[allow(clippy::let_underscore_must_use)]
1646                let _ = self.filesystem.remove_file(path);
1647            }
1648            // Clean up raw log file
1649            if let Some(log_file) = self.terminal_log_files.remove(&terminal_id) {
1650                if backing_file.as_ref() != Some(&log_file) {
1651                    // Best-effort cleanup of temporary terminal files.
1652                    #[allow(clippy::let_underscore_must_use)]
1653                    let _ = self.filesystem.remove_file(&log_file);
1654                }
1655            }
1656
1657            // Remove from terminal_mode_resume to prevent stale entries
1658            self.terminal_mode_resume.remove(&id);
1659
1660            // Exit terminal mode if we were in it
1661            if self.terminal_mode {
1662                self.terminal_mode = false;
1663                self.key_context = crate::input::keybindings::KeyContext::Normal;
1664            }
1665        }
1666
1667        // Find a replacement buffer, preferring the most recently focused one
1668        // First try focus history, then fall back to any visible buffer
1669        let active_split = self.split_manager.active_split();
1670        let replacement_from_history = self.split_view_states.get(&active_split).and_then(|vs| {
1671            // Find the most recently focused buffer that's still open and visible
1672            vs.focus_history
1673                .iter()
1674                .rev()
1675                .find(|&&bid| {
1676                    bid != id
1677                        && self.buffers.contains_key(&bid)
1678                        && !self
1679                            .buffer_metadata
1680                            .get(&bid)
1681                            .map(|m| m.hidden_from_tabs)
1682                            .unwrap_or(false)
1683                })
1684                .copied()
1685        });
1686
1687        // Fall back to any visible buffer if no history match
1688        let visible_replacement = replacement_from_history.or_else(|| {
1689            self.buffers
1690                .keys()
1691                .find(|&&bid| {
1692                    bid != id
1693                        && !self
1694                            .buffer_metadata
1695                            .get(&bid)
1696                            .map(|m| m.hidden_from_tabs)
1697                            .unwrap_or(false)
1698                })
1699                .copied()
1700        });
1701
1702        let is_last_visible_buffer = visible_replacement.is_none();
1703        let replacement_buffer = if is_last_visible_buffer {
1704            self.new_buffer()
1705        } else {
1706            visible_replacement.unwrap()
1707        };
1708
1709        // Switch to replacement buffer BEFORE updating splits.
1710        // This is important because set_active_buffer returns early if the buffer
1711        // is already active, and updating splits changes what active_buffer() returns.
1712        // We need set_active_buffer to run its terminal_mode_resume logic.
1713        if self.active_buffer() == id {
1714            self.set_active_buffer(replacement_buffer);
1715        }
1716
1717        // Update all splits that are showing this buffer to show the replacement
1718        let splits_to_update = self.split_manager.splits_for_buffer(id);
1719        for split_id in splits_to_update {
1720            self.split_manager
1721                .set_split_buffer(split_id, replacement_buffer);
1722        }
1723
1724        self.buffers.remove(&id);
1725        self.event_logs.remove(&id);
1726        self.seen_byte_ranges.remove(&id);
1727        self.buffer_metadata.remove(&id);
1728        if let Some((request_id, _, _)) = self.semantic_tokens_in_flight.remove(&id) {
1729            self.pending_semantic_token_requests.remove(&request_id);
1730        }
1731        if let Some((request_id, _, _, _)) = self.semantic_tokens_range_in_flight.remove(&id) {
1732            self.pending_semantic_token_range_requests
1733                .remove(&request_id);
1734        }
1735        self.semantic_tokens_range_last_request.remove(&id);
1736        self.semantic_tokens_range_applied.remove(&id);
1737        self.semantic_tokens_full_debounce.remove(&id);
1738
1739        // Remove buffer from panel_ids mapping if it was a panel buffer
1740        // This prevents stale entries when the same panel_id is reused later
1741        self.panel_ids.retain(|_, &mut buf_id| buf_id != id);
1742
1743        // Remove buffer from all splits' open_buffers lists and focus history
1744        for view_state in self.split_view_states.values_mut() {
1745            view_state.remove_buffer(id);
1746            view_state.remove_from_history(id);
1747        }
1748
1749        // If this was the last visible buffer, focus file explorer
1750        if is_last_visible_buffer {
1751            self.focus_file_explorer();
1752        }
1753
1754        Ok(())
1755    }
1756
1757    /// Switch to the given buffer
1758    pub fn switch_buffer(&mut self, id: BufferId) {
1759        if self.buffers.contains_key(&id) && id != self.active_buffer() {
1760            // Save current position before switching buffers
1761            self.position_history.commit_pending_movement();
1762
1763            // Also explicitly record current position (in case there was no pending movement)
1764            let cursors = self.active_cursors();
1765            let position = cursors.primary().position;
1766            let anchor = cursors.primary().anchor;
1767            self.position_history
1768                .record_movement(self.active_buffer(), position, anchor);
1769            self.position_history.commit_pending_movement();
1770
1771            self.set_active_buffer(id);
1772        }
1773    }
1774
1775    /// Close the current tab in the current split view.
1776    /// If the tab is the last viewport of the underlying buffer, do the same as close_buffer
1777    /// (including triggering the save/discard prompt for modified buffers).
1778    pub fn close_tab(&mut self) {
1779        let buffer_id = self.active_buffer();
1780        let active_split = self.split_manager.active_split();
1781
1782        // Count how many splits have this buffer in their open_buffers
1783        let buffer_in_other_splits = self
1784            .split_view_states
1785            .iter()
1786            .filter(|(&split_id, view_state)| {
1787                split_id != active_split && view_state.has_buffer(buffer_id)
1788            })
1789            .count();
1790
1791        // Get current split's open buffers
1792        let current_split_tabs = self
1793            .split_view_states
1794            .get(&active_split)
1795            .map(|vs| vs.open_buffers.clone())
1796            .unwrap_or_default();
1797
1798        // If this is the only tab in this split and there are no other splits with this buffer,
1799        // this is the last viewport - behave like close_buffer
1800        let is_last_viewport = buffer_in_other_splits == 0;
1801
1802        if is_last_viewport {
1803            // If this is the only buffer in this split AND there are other splits,
1804            // close the split instead of the buffer (don't create an empty replacement)
1805            let has_other_splits = self.split_manager.root().count_leaves() > 1;
1806            if current_split_tabs.len() <= 1 && has_other_splits {
1807                // Check for unsaved changes first
1808                if self.active_state().buffer.is_modified() {
1809                    let name = self.get_buffer_display_name(buffer_id);
1810                    let save_key = t!("prompt.key.save").to_string();
1811                    let discard_key = t!("prompt.key.discard").to_string();
1812                    let cancel_key = t!("prompt.key.cancel").to_string();
1813                    self.start_prompt(
1814                        t!(
1815                            "prompt.buffer_modified",
1816                            name = name,
1817                            save_key = save_key,
1818                            discard_key = discard_key,
1819                            cancel_key = cancel_key
1820                        )
1821                        .to_string(),
1822                        PromptType::ConfirmCloseBuffer { buffer_id },
1823                    );
1824                    return;
1825                }
1826                // Close the buffer first, then the split
1827                if let Err(e) = self.close_buffer(buffer_id) {
1828                    tracing::warn!("Failed to close buffer: {}", e);
1829                }
1830                self.close_active_split();
1831                return;
1832            }
1833
1834            // Last viewport of this buffer - close the buffer entirely
1835            if self.active_state().buffer.is_modified() {
1836                // Buffer has unsaved changes - prompt for confirmation
1837                let name = self.get_buffer_display_name(buffer_id);
1838                let save_key = t!("prompt.key.save").to_string();
1839                let discard_key = t!("prompt.key.discard").to_string();
1840                let cancel_key = t!("prompt.key.cancel").to_string();
1841                self.start_prompt(
1842                    t!(
1843                        "prompt.buffer_modified",
1844                        name = name,
1845                        save_key = save_key,
1846                        discard_key = discard_key,
1847                        cancel_key = cancel_key
1848                    )
1849                    .to_string(),
1850                    PromptType::ConfirmCloseBuffer { buffer_id },
1851                );
1852            } else if let Err(e) = self.close_buffer(buffer_id) {
1853                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1854            } else {
1855                self.set_status_message(t!("buffer.tab_closed").to_string());
1856            }
1857        } else {
1858            // There are other viewports of this buffer - just remove from current split's tabs
1859            if current_split_tabs.len() <= 1 {
1860                // This is the only tab in this split - close the split
1861                // If we're closing a terminal buffer while in terminal mode, exit terminal mode
1862                if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1863                    self.terminal_mode = false;
1864                    self.key_context = crate::input::keybindings::KeyContext::Normal;
1865                }
1866                self.close_active_split();
1867                return;
1868            }
1869
1870            // Find replacement buffer for this split
1871            let current_idx = current_split_tabs
1872                .iter()
1873                .position(|&id| id == buffer_id)
1874                .unwrap_or(0);
1875            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
1876            let replacement_buffer = current_split_tabs[replacement_idx];
1877
1878            // If we're closing a terminal buffer while in terminal mode, exit terminal mode
1879            if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1880                self.terminal_mode = false;
1881                self.key_context = crate::input::keybindings::KeyContext::Normal;
1882            }
1883
1884            // Remove buffer from this split's tabs
1885            if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1886                view_state.remove_buffer(buffer_id);
1887            }
1888
1889            // Update the split to show the replacement buffer
1890            self.split_manager
1891                .set_split_buffer(active_split, replacement_buffer);
1892
1893            self.set_status_message(t!("buffer.tab_closed").to_string());
1894        }
1895    }
1896
1897    /// Close a specific tab (buffer) in a specific split.
1898    /// Used by mouse click handler on tab close button.
1899    /// Returns true if the tab was closed without needing a prompt.
1900    pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
1901        // If closing a terminal buffer while in terminal mode, exit terminal mode
1902        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1903            self.terminal_mode = false;
1904            self.key_context = crate::input::keybindings::KeyContext::Normal;
1905        }
1906
1907        // Count how many splits have this buffer in their open_buffers
1908        let buffer_in_other_splits = self
1909            .split_view_states
1910            .iter()
1911            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
1912            .count();
1913
1914        // Get the split's open buffers
1915        let split_tabs = self
1916            .split_view_states
1917            .get(&split_id)
1918            .map(|vs| vs.open_buffers.clone())
1919            .unwrap_or_default();
1920
1921        let is_last_viewport = buffer_in_other_splits == 0;
1922
1923        if is_last_viewport {
1924            // Last viewport of this buffer - need to close buffer entirely
1925            if let Some(state) = self.buffers.get(&buffer_id) {
1926                if state.buffer.is_modified() {
1927                    // Buffer has unsaved changes - prompt for confirmation
1928                    let name = self.get_buffer_display_name(buffer_id);
1929                    let save_key = t!("prompt.key.save").to_string();
1930                    let discard_key = t!("prompt.key.discard").to_string();
1931                    let cancel_key = t!("prompt.key.cancel").to_string();
1932                    self.start_prompt(
1933                        t!(
1934                            "prompt.buffer_modified",
1935                            name = name,
1936                            save_key = save_key,
1937                            discard_key = discard_key,
1938                            cancel_key = cancel_key
1939                        )
1940                        .to_string(),
1941                        PromptType::ConfirmCloseBuffer { buffer_id },
1942                    );
1943                    return false;
1944                }
1945            }
1946            if let Err(e) = self.close_buffer(buffer_id) {
1947                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1948            } else {
1949                self.set_status_message(t!("buffer.tab_closed").to_string());
1950            }
1951        } else {
1952            // There are other viewports of this buffer - just remove from this split's tabs
1953            if split_tabs.len() <= 1 {
1954                // This is the only tab in this split - close the split
1955                self.handle_close_split(split_id.into());
1956                return true;
1957            }
1958
1959            // Find replacement buffer for this split
1960            let current_idx = split_tabs
1961                .iter()
1962                .position(|&id| id == buffer_id)
1963                .unwrap_or(0);
1964            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
1965            let replacement_buffer = split_tabs[replacement_idx];
1966
1967            // Remove buffer from this split's tabs
1968            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1969                view_state.remove_buffer(buffer_id);
1970            }
1971
1972            // Update the split to show the replacement buffer
1973            self.split_manager
1974                .set_split_buffer(split_id, replacement_buffer);
1975
1976            self.set_status_message(t!("buffer.tab_closed").to_string());
1977        }
1978        true
1979    }
1980
1981    /// Close all other tabs in a split, keeping only the specified buffer
1982    pub fn close_other_tabs_in_split(&mut self, keep_buffer_id: BufferId, split_id: LeafId) {
1983        // Get the split's open buffers
1984        let split_tabs = self
1985            .split_view_states
1986            .get(&split_id)
1987            .map(|vs| vs.open_buffers.clone())
1988            .unwrap_or_default();
1989
1990        // Close all tabs except the one we want to keep
1991        let tabs_to_close: Vec<_> = split_tabs
1992            .iter()
1993            .filter(|&&id| id != keep_buffer_id)
1994            .copied()
1995            .collect();
1996
1997        let mut closed = 0;
1998        let mut skipped_modified = 0;
1999        for buffer_id in tabs_to_close {
2000            if self.close_tab_in_split_silent(buffer_id, split_id) {
2001                closed += 1;
2002            } else {
2003                skipped_modified += 1;
2004            }
2005        }
2006
2007        // Make sure the kept buffer is active
2008        self.split_manager
2009            .set_split_buffer(split_id, keep_buffer_id);
2010
2011        self.set_batch_close_status_message(closed, skipped_modified);
2012    }
2013
2014    /// Close tabs to the right of the specified buffer in a split
2015    pub fn close_tabs_to_right_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
2016        // Get the split's open buffers
2017        let split_tabs = self
2018            .split_view_states
2019            .get(&split_id)
2020            .map(|vs| vs.open_buffers.clone())
2021            .unwrap_or_default();
2022
2023        // Find the index of the target buffer
2024        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
2025            return;
2026        };
2027
2028        // Close all tabs after the target
2029        let tabs_to_close: Vec<_> = split_tabs.iter().skip(target_idx + 1).copied().collect();
2030
2031        let mut closed = 0;
2032        let mut skipped_modified = 0;
2033        for buf_id in tabs_to_close {
2034            if self.close_tab_in_split_silent(buf_id, split_id) {
2035                closed += 1;
2036            } else {
2037                skipped_modified += 1;
2038            }
2039        }
2040
2041        self.set_batch_close_status_message(closed, skipped_modified);
2042    }
2043
2044    /// Close tabs to the left of the specified buffer in a split
2045    pub fn close_tabs_to_left_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
2046        // Get the split's open buffers
2047        let split_tabs = self
2048            .split_view_states
2049            .get(&split_id)
2050            .map(|vs| vs.open_buffers.clone())
2051            .unwrap_or_default();
2052
2053        // Find the index of the target buffer
2054        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
2055            return;
2056        };
2057
2058        // Close all tabs before the target
2059        let tabs_to_close: Vec<_> = split_tabs.iter().take(target_idx).copied().collect();
2060
2061        let mut closed = 0;
2062        let mut skipped_modified = 0;
2063        for buf_id in tabs_to_close {
2064            if self.close_tab_in_split_silent(buf_id, split_id) {
2065                closed += 1;
2066            } else {
2067                skipped_modified += 1;
2068            }
2069        }
2070
2071        self.set_batch_close_status_message(closed, skipped_modified);
2072    }
2073
2074    /// Close all tabs in a split
2075    pub fn close_all_tabs_in_split(&mut self, split_id: LeafId) {
2076        // Get the split's open buffers
2077        let split_tabs = self
2078            .split_view_states
2079            .get(&split_id)
2080            .map(|vs| vs.open_buffers.clone())
2081            .unwrap_or_default();
2082
2083        let mut closed = 0;
2084        let mut skipped_modified = 0;
2085
2086        // Close all tabs (this will eventually close the split when empty)
2087        for buffer_id in split_tabs {
2088            if self.close_tab_in_split_silent(buffer_id, split_id) {
2089                closed += 1;
2090            } else {
2091                skipped_modified += 1;
2092            }
2093        }
2094
2095        self.set_batch_close_status_message(closed, skipped_modified);
2096    }
2097
2098    /// Set status message for batch close operations
2099    fn set_batch_close_status_message(&mut self, closed: usize, skipped_modified: usize) {
2100        let message = match (closed, skipped_modified) {
2101            (0, 0) => t!("buffer.no_tabs_to_close").to_string(),
2102            (0, n) => t!("buffer.skipped_modified", count = n).to_string(),
2103            (n, 0) => t!("buffer.closed_tabs", count = n).to_string(),
2104            (c, s) => t!("buffer.closed_tabs_skipped", closed = c, skipped = s).to_string(),
2105        };
2106        self.set_status_message(message);
2107    }
2108
2109    /// Close a tab silently (without setting status message)
2110    /// Used internally by batch close operations
2111    /// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
2112    fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
2113        // If closing a terminal buffer while in terminal mode, exit terminal mode
2114        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
2115            self.terminal_mode = false;
2116            self.key_context = crate::input::keybindings::KeyContext::Normal;
2117        }
2118
2119        // Count how many splits have this buffer in their open_buffers
2120        let buffer_in_other_splits = self
2121            .split_view_states
2122            .iter()
2123            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
2124            .count();
2125
2126        // Get the split's open buffers
2127        let split_tabs = self
2128            .split_view_states
2129            .get(&split_id)
2130            .map(|vs| vs.open_buffers.clone())
2131            .unwrap_or_default();
2132
2133        let is_last_viewport = buffer_in_other_splits == 0;
2134
2135        if is_last_viewport {
2136            // Last viewport of this buffer - need to close buffer entirely
2137            // Skip modified buffers to avoid prompting during batch operations
2138            if let Some(state) = self.buffers.get(&buffer_id) {
2139                if state.buffer.is_modified() {
2140                    // Skip modified buffers - don't close them
2141                    return false;
2142                }
2143            }
2144            if let Err(e) = self.close_buffer(buffer_id) {
2145                tracing::warn!("Failed to close buffer: {}", e);
2146            }
2147            true
2148        } else {
2149            // There are other viewports of this buffer - just remove from this split's tabs
2150            if split_tabs.len() <= 1 {
2151                // This is the only tab in this split - close the split
2152                self.handle_close_split(split_id.into());
2153                return true;
2154            }
2155
2156            // Find replacement buffer for this split
2157            let current_idx = split_tabs
2158                .iter()
2159                .position(|&id| id == buffer_id)
2160                .unwrap_or(0);
2161            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
2162            let replacement_buffer = split_tabs.get(replacement_idx).copied();
2163
2164            // Remove buffer from this split's tabs
2165            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2166                view_state.remove_buffer(buffer_id);
2167            }
2168
2169            // Update the split to show the replacement buffer
2170            if let Some(replacement) = replacement_buffer {
2171                self.split_manager.set_split_buffer(split_id, replacement);
2172            }
2173            true
2174        }
2175    }
2176
2177    /// Get visible (non-hidden) buffers for the current split.
2178    /// This filters out buffers with hidden_from_tabs=true.
2179    fn visible_buffers_for_active_split(&self) -> Vec<BufferId> {
2180        let active_split = self.split_manager.active_split();
2181        if let Some(view_state) = self.split_view_states.get(&active_split) {
2182            view_state
2183                .open_buffers
2184                .iter()
2185                .copied()
2186                .filter(|id| {
2187                    !self
2188                        .buffer_metadata
2189                        .get(id)
2190                        .map(|m| m.hidden_from_tabs)
2191                        .unwrap_or(false)
2192                })
2193                .collect()
2194        } else {
2195            // Fallback to all visible buffers if no view state
2196            let mut all_ids: Vec<_> = self
2197                .buffers
2198                .keys()
2199                .copied()
2200                .filter(|id| {
2201                    !self
2202                        .buffer_metadata
2203                        .get(id)
2204                        .map(|m| m.hidden_from_tabs)
2205                        .unwrap_or(false)
2206                })
2207                .collect();
2208            all_ids.sort_by_key(|id| id.0);
2209            all_ids
2210        }
2211    }
2212
2213    /// Switch to next buffer in current split's tabs
2214    pub fn next_buffer(&mut self) {
2215        let ids = self.visible_buffers_for_active_split();
2216
2217        if ids.is_empty() {
2218            return;
2219        }
2220
2221        if let Some(idx) = ids.iter().position(|&id| id == self.active_buffer()) {
2222            let next_idx = (idx + 1) % ids.len();
2223            if ids[next_idx] != self.active_buffer() {
2224                // Save current position before switching
2225                self.position_history.commit_pending_movement();
2226
2227                // Also explicitly record current position
2228                let cursors = self.active_cursors();
2229                let position = cursors.primary().position;
2230                let anchor = cursors.primary().anchor;
2231                self.position_history
2232                    .record_movement(self.active_buffer(), position, anchor);
2233                self.position_history.commit_pending_movement();
2234
2235                self.set_active_buffer(ids[next_idx]);
2236            }
2237        }
2238    }
2239
2240    /// Switch to previous buffer in current split's tabs
2241    pub fn prev_buffer(&mut self) {
2242        let ids = self.visible_buffers_for_active_split();
2243
2244        if ids.is_empty() {
2245            return;
2246        }
2247
2248        if let Some(idx) = ids.iter().position(|&id| id == self.active_buffer()) {
2249            let prev_idx = if idx == 0 { ids.len() - 1 } else { idx - 1 };
2250            if ids[prev_idx] != self.active_buffer() {
2251                // Save current position before switching
2252                self.position_history.commit_pending_movement();
2253
2254                // Also explicitly record current position
2255                let cursors = self.active_cursors();
2256                let position = cursors.primary().position;
2257                let anchor = cursors.primary().anchor;
2258                self.position_history
2259                    .record_movement(self.active_buffer(), position, anchor);
2260                self.position_history.commit_pending_movement();
2261
2262                self.set_active_buffer(ids[prev_idx]);
2263            }
2264        }
2265    }
2266
2267    /// Navigate back in position history
2268    pub fn navigate_back(&mut self) {
2269        // Set flag to prevent recording this navigation movement
2270        self.in_navigation = true;
2271
2272        // Commit any pending movement
2273        self.position_history.commit_pending_movement();
2274
2275        // If we're at the end of history (haven't used back yet), save current position
2276        // so we can navigate forward to it later
2277        if self.position_history.can_go_back() && !self.position_history.can_go_forward() {
2278            let cursors = self.active_cursors();
2279            let position = cursors.primary().position;
2280            let anchor = cursors.primary().anchor;
2281            self.position_history
2282                .record_movement(self.active_buffer(), position, anchor);
2283            self.position_history.commit_pending_movement();
2284        }
2285
2286        // Navigate to the previous position
2287        if let Some(entry) = self.position_history.back() {
2288            let target_buffer = entry.buffer_id;
2289            let target_position = entry.position;
2290            let target_anchor = entry.anchor;
2291
2292            // Switch to the target buffer
2293            if self.buffers.contains_key(&target_buffer) {
2294                self.set_active_buffer(target_buffer);
2295
2296                // Move cursor to the saved position
2297                let cursors = self.active_cursors();
2298                let cursor_id = cursors.primary_id();
2299                let old_position = cursors.primary().position;
2300                let old_anchor = cursors.primary().anchor;
2301                let old_sticky_column = cursors.primary().sticky_column;
2302                let event = Event::MoveCursor {
2303                    cursor_id,
2304                    old_position,
2305                    new_position: target_position,
2306                    old_anchor,
2307                    new_anchor: target_anchor,
2308                    old_sticky_column,
2309                    new_sticky_column: 0, // Reset sticky column for navigation
2310                };
2311                let split_id = self.split_manager.active_split();
2312                let state = self.buffers.get_mut(&target_buffer).unwrap();
2313                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
2314                state.apply(&mut view_state.cursors, &event);
2315            }
2316        }
2317
2318        // Clear the flag
2319        self.in_navigation = false;
2320    }
2321
2322    /// Navigate forward in position history
2323    pub fn navigate_forward(&mut self) {
2324        // Set flag to prevent recording this navigation movement
2325        self.in_navigation = true;
2326
2327        if let Some(entry) = self.position_history.forward() {
2328            let target_buffer = entry.buffer_id;
2329            let target_position = entry.position;
2330            let target_anchor = entry.anchor;
2331
2332            // Switch to the target buffer
2333            if self.buffers.contains_key(&target_buffer) {
2334                self.set_active_buffer(target_buffer);
2335
2336                // Move cursor to the saved position
2337                let cursors = self.active_cursors();
2338                let cursor_id = cursors.primary_id();
2339                let old_position = cursors.primary().position;
2340                let old_anchor = cursors.primary().anchor;
2341                let old_sticky_column = cursors.primary().sticky_column;
2342                let event = Event::MoveCursor {
2343                    cursor_id,
2344                    old_position,
2345                    new_position: target_position,
2346                    old_anchor,
2347                    new_anchor: target_anchor,
2348                    old_sticky_column,
2349                    new_sticky_column: 0, // Reset sticky column for navigation
2350                };
2351                let split_id = self.split_manager.active_split();
2352                let state = self.buffers.get_mut(&target_buffer).unwrap();
2353                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
2354                state.apply(&mut view_state.cursors, &event);
2355            }
2356        }
2357
2358        // Clear the flag
2359        self.in_navigation = false;
2360    }
2361
2362    /// Get the current mouse hover state for testing
2363    /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
2364    pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
2365        self.mouse_state
2366            .lsp_hover_state
2367            .map(|(pos, _, x, y)| (pos, x, y))
2368    }
2369
2370    /// Check if a transient popup (hover/signature help) is currently visible
2371    pub fn has_transient_popup(&self) -> bool {
2372        self.active_state()
2373            .popups
2374            .top()
2375            .is_some_and(|p| p.transient)
2376    }
2377
2378    /// Force check the mouse hover timer (for testing)
2379    /// This bypasses the normal 500ms delay
2380    pub fn force_check_mouse_hover(&mut self) -> bool {
2381        // Temporarily mark the hover as ready by checking if state exists
2382        if let Some((byte_pos, _, screen_x, screen_y)) = self.mouse_state.lsp_hover_state {
2383            if !self.mouse_state.lsp_hover_request_sent {
2384                self.mouse_state.lsp_hover_request_sent = true;
2385                self.mouse_hover_screen_position = Some((screen_x, screen_y));
2386                if let Err(e) = self.request_hover_at_position(byte_pos) {
2387                    tracing::debug!("Failed to request hover: {}", e);
2388                    return false;
2389                }
2390                return true;
2391            }
2392        }
2393        false
2394    }
2395
2396    /// Queue a file to be opened after the TUI starts.
2397    ///
2398    /// This is used for CLI file arguments to ensure they go through the same
2399    /// code path as interactive file opens, providing consistent error handling
2400    /// (e.g., encoding confirmation prompts are shown in the UI instead of crashing).
2401    pub fn queue_file_open(
2402        &mut self,
2403        path: PathBuf,
2404        line: Option<usize>,
2405        column: Option<usize>,
2406        end_line: Option<usize>,
2407        end_column: Option<usize>,
2408        message: Option<String>,
2409        wait_id: Option<u64>,
2410    ) {
2411        self.pending_file_opens.push(super::PendingFileOpen {
2412            path,
2413            line,
2414            column,
2415            end_line,
2416            end_column,
2417            message,
2418            wait_id,
2419        });
2420    }
2421
2422    /// Process pending file opens (called from the event loop).
2423    ///
2424    /// Opens files that were queued during startup, using the same error handling
2425    /// as interactive file opens. Returns true if any files were processed.
2426    pub fn process_pending_file_opens(&mut self) -> bool {
2427        if self.pending_file_opens.is_empty() {
2428            return false;
2429        }
2430
2431        // Take all pending files to process
2432        let pending = std::mem::take(&mut self.pending_file_opens);
2433        let mut processed_any = false;
2434
2435        for pending_file in pending {
2436            tracing::info!(
2437                "[SYNTAX DEBUG] Processing pending file open: {:?}",
2438                pending_file.path
2439            );
2440
2441            match self.open_file(&pending_file.path) {
2442                Ok(_) => {
2443                    // Navigate to line/column or select range if specified
2444                    if let (Some(line), Some(end_line)) = (pending_file.line, pending_file.end_line)
2445                    {
2446                        self.select_range(
2447                            line,
2448                            pending_file.column,
2449                            end_line,
2450                            pending_file.end_column,
2451                        );
2452                    } else if let Some(line) = pending_file.line {
2453                        self.goto_line_col(line, pending_file.column);
2454                    }
2455                    // Show hover message popup if specified
2456                    let has_popup = pending_file.message.is_some();
2457                    if let Some(ref msg) = pending_file.message {
2458                        self.show_file_message_popup(msg);
2459                    }
2460                    // Track wait ID for --wait support
2461                    if let Some(wait_id) = pending_file.wait_id {
2462                        let buffer_id = self.active_buffer();
2463                        self.wait_tracking.insert(buffer_id, (wait_id, has_popup));
2464                    }
2465                    processed_any = true;
2466                }
2467                Err(e) => {
2468                    // Check if this is a large file encoding confirmation error
2469                    // Show prompt instead of crashing
2470                    if let Some(confirmation) =
2471                        e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
2472                    {
2473                        self.start_large_file_encoding_confirmation(confirmation);
2474                    } else {
2475                        // For other errors, show status message (consistent with file browser)
2476                        self.set_status_message(
2477                            t!("file.error_opening", error = e.to_string()).to_string(),
2478                        );
2479                    }
2480                    processed_any = true;
2481                }
2482            }
2483        }
2484
2485        processed_any
2486    }
2487
2488    /// Take and return completed wait IDs (for --wait support).
2489    pub fn take_completed_waits(&mut self) -> Vec<u64> {
2490        std::mem::take(&mut self.completed_waits)
2491    }
2492
2493    /// Remove wait tracking for a given wait_id (e.g., when waiting client disconnects).
2494    pub fn remove_wait_tracking(&mut self, wait_id: u64) {
2495        self.wait_tracking.retain(|_, (wid, _)| *wid != wait_id);
2496    }
2497
2498    /// Start an incremental line-feed scan for the active buffer.
2499    ///
2500    /// Shared by the `Action::ScanLineIndex` command and the Go to Line scan
2501    /// confirmation prompt. Sets up `LineScanState` so that `process_line_scan`
2502    /// will advance the scan one batch per frame.
2503    ///
2504    /// When `open_goto_line` is true (Go to Line flow), the Go to Line prompt
2505    /// opens automatically when the scan completes.
2506    pub fn start_incremental_line_scan(&mut self, open_goto_line: bool) {
2507        let buffer_id = self.active_buffer();
2508        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2509            let (chunks, total_bytes) = state.buffer.prepare_line_scan();
2510            let leaves = state.buffer.piece_tree_leaves();
2511            self.line_scan_state = Some(super::LineScanState {
2512                buffer_id,
2513                leaves,
2514                chunks,
2515                next_chunk: 0,
2516                total_bytes,
2517                scanned_bytes: 0,
2518                updates: Vec::new(),
2519                open_goto_line_on_complete: open_goto_line,
2520            });
2521            self.set_status_message(t!("goto.scanning_progress", percent = 0).to_string());
2522        }
2523    }
2524
2525    /// Process chunks for the incremental line-feed scan.
2526    /// Returns `true` if the UI should re-render (progress updated or scan finished).
2527    pub fn process_line_scan(&mut self) -> bool {
2528        let _span = tracing::info_span!("process_line_scan").entered();
2529        let scan = match self.line_scan_state.as_mut() {
2530            Some(s) => s,
2531            None => return false,
2532        };
2533
2534        let buffer_id = scan.buffer_id;
2535
2536        if let Err(e) = self.process_line_scan_batch(buffer_id) {
2537            tracing::warn!("Line scan error: {e}");
2538            self.finish_line_scan_with_error(e);
2539            return true;
2540        }
2541
2542        let scan = self.line_scan_state.as_ref().unwrap();
2543        if scan.next_chunk >= scan.chunks.len() {
2544            self.finish_line_scan_ok();
2545        } else {
2546            let pct = if scan.total_bytes > 0 {
2547                (scan.scanned_bytes * 100) / scan.total_bytes
2548            } else {
2549                100
2550            };
2551            self.set_status_message(t!("goto.scanning_progress", percent = pct).to_string());
2552        }
2553        true
2554    }
2555
2556    /// Process leaves concurrently, yielding for a render after each batch.
2557    ///
2558    /// For loaded leaves, delegates to `TextBuffer::scan_leaf` (shared counting
2559    /// logic). For unloaded leaves, extracts I/O parameters and runs them
2560    /// concurrently using `tokio::task::spawn_blocking` — each task calls
2561    /// `count_line_feeds_in_range` on the filesystem, which remote implementations
2562    /// override to count on the server without transferring data.
2563    fn process_line_scan_batch(&mut self, buffer_id: BufferId) -> std::io::Result<()> {
2564        let _span = tracing::info_span!("process_line_scan_batch").entered();
2565        let concurrency = self.config.editor.read_concurrency.max(1);
2566
2567        let state = self.buffers.get(&buffer_id);
2568        let scan = self.line_scan_state.as_mut().unwrap();
2569
2570        let mut results: Vec<(usize, usize)> = Vec::new();
2571        let mut io_work: Vec<(usize, std::path::PathBuf, u64, usize)> = Vec::new();
2572
2573        while scan.next_chunk < scan.chunks.len() && (results.len() + io_work.len()) < concurrency {
2574            let chunk = scan.chunks[scan.next_chunk].clone();
2575            scan.next_chunk += 1;
2576            scan.scanned_bytes += chunk.byte_len;
2577
2578            if chunk.already_known {
2579                continue;
2580            }
2581
2582            if let Some(state) = state {
2583                let leaf = &scan.leaves[chunk.leaf_index];
2584
2585                // Use scan_leaf for loaded buffers (shared counting logic with
2586                // the TextBuffer-level scan). For unloaded buffers, collect I/O
2587                // parameters for concurrent filesystem access.
2588                match state.buffer.leaf_io_params(leaf) {
2589                    None => {
2590                        // Loaded: count in-memory via scan_leaf
2591                        let count = state.buffer.scan_leaf(leaf)?;
2592                        results.push((chunk.leaf_index, count));
2593                    }
2594                    Some((path, offset, len)) => {
2595                        // Unloaded: batch for concurrent I/O
2596                        io_work.push((chunk.leaf_index, path, offset, len));
2597                    }
2598                }
2599            }
2600        }
2601
2602        // Run I/O concurrently using tokio::task::spawn_blocking
2603        if !io_work.is_empty() {
2604            let fs = match state {
2605                Some(s) => s.buffer.filesystem().clone(),
2606                None => return Ok(()),
2607            };
2608
2609            let rt = self
2610                .tokio_runtime
2611                .as_ref()
2612                .ok_or_else(|| std::io::Error::other("async runtime not available"))?;
2613
2614            let io_results: Vec<std::io::Result<(usize, usize)>> = rt.block_on(async {
2615                let mut handles = Vec::with_capacity(io_work.len());
2616                for (leaf_idx, path, offset, len) in io_work {
2617                    let fs = fs.clone();
2618                    handles.push(tokio::task::spawn_blocking(move || {
2619                        let count = fs.count_line_feeds_in_range(&path, offset, len)?;
2620                        Ok((leaf_idx, count))
2621                    }));
2622                }
2623
2624                let mut results = Vec::with_capacity(handles.len());
2625                for handle in handles {
2626                    results.push(handle.await.unwrap());
2627                }
2628                results
2629            });
2630
2631            for result in io_results {
2632                results.push(result?);
2633            }
2634        }
2635
2636        for (leaf_idx, count) in results {
2637            scan.updates.push((leaf_idx, count));
2638        }
2639
2640        Ok(())
2641    }
2642
2643    fn finish_line_scan_ok(&mut self) {
2644        let _span = tracing::info_span!("finish_line_scan_ok").entered();
2645        let scan = self.line_scan_state.take().unwrap();
2646        let open_goto = scan.open_goto_line_on_complete;
2647        if let Some(state) = self.buffers.get_mut(&scan.buffer_id) {
2648            let _span = tracing::info_span!(
2649                "rebuild_with_pristine_saved_root",
2650                updates = scan.updates.len()
2651            )
2652            .entered();
2653            state.buffer.rebuild_with_pristine_saved_root(&scan.updates);
2654        }
2655        self.set_status_message(t!("goto.scan_complete").to_string());
2656        if open_goto {
2657            self.open_goto_line_if_active(scan.buffer_id);
2658        }
2659    }
2660
2661    fn finish_line_scan_with_error(&mut self, e: std::io::Error) {
2662        let scan = self.line_scan_state.take().unwrap();
2663        let open_goto = scan.open_goto_line_on_complete;
2664        self.set_status_message(t!("goto.scan_failed", error = e.to_string()).to_string());
2665        if open_goto {
2666            self.open_goto_line_if_active(scan.buffer_id);
2667        }
2668    }
2669
2670    fn open_goto_line_if_active(&mut self, buffer_id: BufferId) {
2671        if self.active_buffer() == buffer_id {
2672            self.start_prompt(
2673                t!("file.goto_line_prompt").to_string(),
2674                PromptType::GotoLine,
2675            );
2676        }
2677    }
2678
2679    // === Incremental Search Scan (for large files) ===
2680
2681    /// Process chunks for the incremental search scan.
2682    /// Returns `true` if the UI should re-render (progress updated or scan finished).
2683    pub fn process_search_scan(&mut self) -> bool {
2684        let scan = match self.search_scan_state.as_mut() {
2685            Some(s) => s,
2686            None => return false,
2687        };
2688
2689        let buffer_id = scan.buffer_id;
2690
2691        if let Err(e) = self.process_search_scan_batch(buffer_id) {
2692            tracing::warn!("Search scan error: {e}");
2693            let _scan = self.search_scan_state.take().unwrap();
2694            self.set_status_message(format!("Search failed: {e}"));
2695            return true;
2696        }
2697
2698        let scan = self.search_scan_state.as_ref().unwrap();
2699        if scan.next_chunk >= scan.chunks.len() || scan.capped {
2700            self.finish_search_scan();
2701        } else {
2702            let pct = if scan.total_bytes > 0 {
2703                (scan.scanned_bytes * 100) / scan.total_bytes
2704            } else {
2705                100
2706            };
2707            let match_count = scan.match_ranges.len();
2708            self.set_status_message(format!(
2709                "Searching... {}% ({} matches so far)",
2710                pct, match_count
2711            ));
2712        }
2713        true
2714    }
2715
2716    /// Process a batch of search chunks.  For each chunk, loads (or reads
2717    /// in-memory) the leaf data, prepends any overlap tail from the previous
2718    /// chunk to handle cross-boundary matches, runs the regex, and
2719    /// accumulates results.
2720    ///
2721    /// To avoid double-mutable-borrow issues (scan state + buffer state are
2722    /// both on `self`), each iteration first extracts the work item from the
2723    /// scan state, then accesses the buffer, then writes results back.
2724    fn process_search_scan_batch(
2725        &mut self,
2726        buffer_id: crate::model::event::BufferId,
2727    ) -> std::io::Result<()> {
2728        let concurrency = self.config.editor.read_concurrency.max(1);
2729
2730        for _ in 0..concurrency {
2731            // --- Phase 1: Extract next work item from scan state ---
2732            let (doc_offset, byte_len, overlap_tail, overlap_doc_offset, query_len) = {
2733                let scan = match self.search_scan_state.as_mut() {
2734                    Some(s) => s,
2735                    None => return Ok(()),
2736                };
2737                if scan.next_chunk >= scan.chunks.len() || scan.capped {
2738                    break;
2739                }
2740                let chunk = scan.chunks[scan.next_chunk].clone();
2741                scan.next_chunk += 1;
2742                scan.scanned_bytes += chunk.byte_len;
2743
2744                let doc_offset = scan.next_doc_offset;
2745                scan.next_doc_offset += chunk.byte_len;
2746
2747                let overlap_tail = scan.overlap_tail.clone();
2748                let overlap_doc_offset = scan.overlap_doc_offset;
2749                let query_len = scan.query.len();
2750
2751                (
2752                    doc_offset,
2753                    chunk.byte_len,
2754                    overlap_tail,
2755                    overlap_doc_offset,
2756                    query_len,
2757                )
2758            };
2759
2760            // --- Phase 2: Load the chunk bytes from the buffer ---
2761            let chunk_bytes: Vec<u8> = if let Some(state) = self.buffers.get_mut(&buffer_id) {
2762                match state.buffer.get_text_range_mut(doc_offset, byte_len) {
2763                    Ok(bytes) => bytes,
2764                    Err(e) => {
2765                        tracing::warn!("Failed to read chunk at offset {}: {}", doc_offset, e);
2766                        continue;
2767                    }
2768                }
2769            } else {
2770                continue;
2771            };
2772
2773            // --- Phase 3: Search and write results back to scan state ---
2774            let scan = self.search_scan_state.as_mut().unwrap();
2775
2776            let overlap_len = overlap_tail.len();
2777            let mut search_buf = Vec::with_capacity(overlap_len + chunk_bytes.len());
2778            search_buf.extend_from_slice(&overlap_tail);
2779            search_buf.extend_from_slice(&chunk_bytes);
2780
2781            let buf_doc_offset = if overlap_len > 0 {
2782                overlap_doc_offset
2783            } else {
2784                doc_offset
2785            };
2786
2787            let search_text = String::from_utf8_lossy(&search_buf);
2788            for m in scan.regex.find_iter(&search_text) {
2789                let match_doc_offset = buf_doc_offset + m.start();
2790                let match_len = m.end() - m.start();
2791
2792                // Skip matches entirely within the overlap (already found)
2793                if overlap_len > 0 && m.end() <= overlap_len {
2794                    continue;
2795                }
2796
2797                if scan.match_ranges.len() >= SearchState::MAX_MATCHES {
2798                    scan.capped = true;
2799                    break;
2800                }
2801
2802                scan.match_ranges.push((match_doc_offset, match_len));
2803            }
2804
2805            // Save overlap tail for next chunk
2806            let max_overlap = query_len.max(256).min(chunk_bytes.len());
2807            let tail_start = chunk_bytes.len().saturating_sub(max_overlap);
2808            scan.overlap_tail = chunk_bytes[tail_start..].to_vec();
2809            scan.overlap_doc_offset = doc_offset + tail_start;
2810        }
2811
2812        Ok(())
2813    }
2814
2815    /// Finalize the incremental search scan: take the accumulated matches
2816    /// and hand them to `finalize_search()` which sets search_state, moves
2817    /// the cursor, and creates viewport overlays.
2818    fn finish_search_scan(&mut self) {
2819        let scan = self.search_scan_state.take().unwrap();
2820        let buffer_id = scan.buffer_id;
2821        let match_ranges = scan.match_ranges;
2822        let capped = scan.capped;
2823        let query = scan.query;
2824
2825        // The search scan loaded chunks via chunk_split_and_load, which
2826        // restructures the piece tree.  Refresh saved_root so that
2827        // diff_since_saved() can take the fast Arc::ptr_eq path.
2828        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2829            state.buffer.refresh_saved_root_if_unmodified();
2830        }
2831
2832        if match_ranges.is_empty() {
2833            self.search_state = None;
2834            self.set_status_message(format!("No matches found for '{}'", query));
2835            return;
2836        }
2837
2838        self.finalize_search(&query, match_ranges, capped, None);
2839    }
2840}