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