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