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;
14use std::sync::Arc;
15
16use crate::app::warning_domains::WarningDomain;
17use crate::model::event::{BufferId, Event, SplitId};
18use crate::services::lsp::manager::detect_language;
19use crate::state::EditorState;
20use crate::view::prompt::PromptType;
21use crate::view::split::SplitViewState;
22
23use super::help;
24use super::Editor;
25
26impl Editor {
27    /// Open a file and return its buffer ID
28    ///
29    /// If the file doesn't exist, creates an unsaved buffer with that filename.
30    /// Saving the buffer will create the file.
31    pub fn open_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
32        let buffer_id = self.open_file_no_focus(path)?;
33
34        // Check if this was an already-open buffer or a new one
35        // For already-open buffers, just switch to them
36        // For new buffers, record position history before switching
37        let is_new_buffer = self.active_buffer() != buffer_id;
38
39        if is_new_buffer {
40            // Save current position before switching to new buffer
41            self.position_history.commit_pending_movement();
42
43            // Explicitly record current position before switching
44            let current_state = self.active_state();
45            let position = current_state.cursors.primary().position;
46            let anchor = current_state.cursors.primary().anchor;
47            self.position_history
48                .record_movement(self.active_buffer(), position, anchor);
49            self.position_history.commit_pending_movement();
50        }
51
52        self.set_active_buffer(buffer_id);
53
54        // Use display_name from metadata for relative path display
55        let display_name = self
56            .buffer_metadata
57            .get(&buffer_id)
58            .map(|m| m.display_name.clone())
59            .unwrap_or_else(|| path.display().to_string());
60
61        // Check if buffer is binary for status message
62        let is_binary = self
63            .buffers
64            .get(&buffer_id)
65            .map(|s| s.buffer.is_binary())
66            .unwrap_or(false);
67
68        // Show appropriate status message for binary vs regular files
69        if is_binary {
70            self.status_message = Some(t!("buffer.opened_binary", name = display_name).to_string());
71        } else {
72            self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
73        }
74
75        Ok(buffer_id)
76    }
77
78    /// Open a file without switching focus to it
79    ///
80    /// Creates a new buffer for the file (or returns existing buffer ID if already open)
81    /// but does not change the active buffer. Useful for opening files in background tabs.
82    ///
83    /// If the file doesn't exist, creates an unsaved buffer with that filename.
84    pub fn open_file_no_focus(&mut self, path: &Path) -> anyhow::Result<BufferId> {
85        // Resolve relative paths against appropriate base directory
86        // For remote mode, use the remote home directory; for local, use working_dir
87        let base_dir = if self.filesystem.remote_connection_info().is_some() {
88            self.filesystem
89                .home_dir()
90                .unwrap_or_else(|_| self.working_dir.clone())
91        } else {
92            self.working_dir.clone()
93        };
94
95        let resolved_path = if path.is_relative() {
96            base_dir.join(path)
97        } else {
98            path.to_path_buf()
99        };
100
101        // Determine if we're opening a non-existent file (for creating new files)
102        // Use filesystem trait method to support remote files
103        let file_exists = self.filesystem.exists(&resolved_path);
104
105        // Canonicalize the path to resolve symlinks and normalize path components
106        // This ensures consistent path representation throughout the editor
107        // For non-existent files, we need to canonicalize the parent directory and append the filename
108        let canonical_path = if file_exists {
109            self.filesystem
110                .canonicalize(&resolved_path)
111                .unwrap_or_else(|_| resolved_path.clone())
112        } else {
113            // For non-existent files, canonicalize parent dir and append filename
114            if let Some(parent) = resolved_path.parent() {
115                let canonical_parent = if parent.as_os_str().is_empty() {
116                    // No parent means just a filename, use base dir
117                    base_dir.clone()
118                } else {
119                    self.filesystem
120                        .canonicalize(parent)
121                        .unwrap_or_else(|_| parent.to_path_buf())
122                };
123                if let Some(filename) = resolved_path.file_name() {
124                    canonical_parent.join(filename)
125                } else {
126                    resolved_path
127                }
128            } else {
129                resolved_path
130            }
131        };
132        let path = canonical_path.as_path();
133
134        // Check if the path is a directory (after following symlinks via canonicalize)
135        // Directories cannot be opened as files in the editor
136        // Use filesystem trait method to support remote files
137        if self.filesystem.is_dir(path).unwrap_or(false) {
138            anyhow::bail!(t!("buffer.cannot_open_directory"));
139        }
140
141        // Check if file is already open - return existing buffer without switching
142        let already_open = self
143            .buffers
144            .iter()
145            .find(|(_, state)| state.buffer.file_path() == Some(path))
146            .map(|(id, _)| *id);
147
148        if let Some(id) = already_open {
149            return Ok(id);
150        }
151
152        // If the current buffer is empty and unmodified, replace it instead of creating a new one
153        // Note: Don't replace composite buffers (they appear empty but are special views)
154        let replace_current = {
155            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
156            !current_state.is_composite_buffer
157                && current_state.buffer.is_empty()
158                && !current_state.buffer.is_modified()
159                && current_state.buffer.file_path().is_none()
160        };
161
162        let buffer_id = if replace_current {
163            // Reuse the current empty buffer
164            self.active_buffer()
165        } else {
166            // Create new buffer for this file
167            let id = BufferId(self.next_buffer_id);
168            self.next_buffer_id += 1;
169            id
170        };
171
172        // Create the editor state - either load from file or create empty buffer
173        tracing::info!(
174            "[SYNTAX DEBUG] open_file_no_focus: path={:?}, extension={:?}, registry_syntaxes={}, user_extensions={:?}",
175            path,
176            path.extension(),
177            self.grammar_registry.available_syntaxes().len(),
178            self.grammar_registry.user_extensions_debug()
179        );
180        let mut state = if file_exists {
181            EditorState::from_file_with_languages(
182                path,
183                self.terminal_width,
184                self.terminal_height,
185                self.config.editor.large_file_threshold_bytes as usize,
186                &self.grammar_registry,
187                &self.config.languages,
188                Arc::clone(&self.filesystem),
189            )?
190        } else {
191            // File doesn't exist - create empty buffer with the file path set
192            let mut new_state = EditorState::new(
193                self.terminal_width,
194                self.terminal_height,
195                self.config.editor.large_file_threshold_bytes as usize,
196                Arc::clone(&self.filesystem),
197            );
198            // Set the file path so saving will create the file
199            new_state.buffer.set_file_path(path.to_path_buf());
200            new_state
201        };
202        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
203
204        // Check if the buffer contains binary content
205        let is_binary = state.buffer.is_binary();
206        if is_binary {
207            // Make binary buffers read-only
208            state.editing_disabled = true;
209            tracing::info!("Detected binary file: {}", path.display());
210        }
211
212        // Set show_whitespace_tabs, use_tabs, and tab_size based on language config
213        // with fallback to global editor config for tab_size
214        if let Some(language) = detect_language(path, &self.config.languages) {
215            if let Some(lang_config) = self.config.languages.get(&language) {
216                state.show_whitespace_tabs = lang_config.show_whitespace_tabs;
217                state.use_tabs = lang_config.use_tabs;
218                // Use language-specific tab_size if set, otherwise fall back to global
219                state.tab_size = lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
220            } else {
221                state.tab_size = self.config.editor.tab_size;
222            }
223        } else {
224            state.tab_size = self.config.editor.tab_size;
225        }
226
227        // Apply line_numbers default from config
228        state
229            .margins
230            .set_line_numbers(self.config.editor.line_numbers);
231
232        self.buffers.insert(buffer_id, state);
233        self.event_logs
234            .insert(buffer_id, crate::model::event::EventLog::new());
235
236        // Create metadata for this buffer
237        let mut metadata =
238            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
239
240        // Mark binary files in metadata and disable LSP
241        if is_binary {
242            metadata.binary = true;
243            metadata.read_only = true;
244            metadata.disable_lsp(t!("buffer.binary_file").to_string());
245        }
246
247        // Notify LSP about the newly opened file (skip for binary files)
248        if !is_binary {
249            self.notify_lsp_file_opened(path, buffer_id, &mut metadata);
250        }
251
252        // Store metadata for this buffer
253        self.buffer_metadata.insert(buffer_id, metadata);
254
255        // Add buffer to the active split's tabs (but don't switch to it)
256        let active_split = self.split_manager.active_split();
257        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
258            view_state.add_buffer(buffer_id);
259            // Apply line_wrap default from config (per-view setting, applies to split)
260            view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
261        }
262
263        // Restore global file state (scroll/cursor position) if available
264        // This persists file positions across projects and editor instances
265        self.restore_global_file_state(buffer_id, path, active_split);
266
267        // Emit control event
268        self.emit_event(
269            crate::model::control_event::events::FILE_OPENED.name,
270            serde_json::json!({
271                "path": path.display().to_string(),
272                "buffer_id": buffer_id.0
273            }),
274        );
275
276        // Track file for auto-revert and conflict detection
277        self.watch_file(path);
278
279        // Fire AfterFileOpen hook for plugins
280        self.plugin_manager.run_hook(
281            "after_file_open",
282            crate::services::plugins::hooks::HookArgs::AfterFileOpen {
283                buffer_id,
284                path: path.to_path_buf(),
285            },
286        );
287
288        Ok(buffer_id)
289    }
290
291    /// Open a local file (always uses local filesystem, not remote)
292    ///
293    /// This is used for opening local files like log files when in remote mode.
294    /// Unlike `open_file`, this always uses the local filesystem even when
295    /// the editor is connected to a remote server.
296    pub fn open_local_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
297        // Resolve relative paths against working_dir
298        let resolved_path = if path.is_relative() {
299            self.working_dir.join(path)
300        } else {
301            path.to_path_buf()
302        };
303
304        // Canonicalize the path
305        let canonical_path = resolved_path
306            .canonicalize()
307            .unwrap_or_else(|_| resolved_path.clone());
308        let path = canonical_path.as_path();
309
310        // Check if already open
311        let already_open = self
312            .buffers
313            .iter()
314            .find(|(_, state)| state.buffer.file_path() == Some(path))
315            .map(|(id, _)| *id);
316
317        if let Some(id) = already_open {
318            self.set_active_buffer(id);
319            return Ok(id);
320        }
321
322        // Create new buffer
323        let buffer_id = BufferId(self.next_buffer_id);
324        self.next_buffer_id += 1;
325
326        // Create editor state using LOCAL filesystem
327        let state = EditorState::from_file_with_languages(
328            path,
329            self.terminal_width,
330            self.terminal_height,
331            self.config.editor.large_file_threshold_bytes as usize,
332            &self.grammar_registry,
333            &self.config.languages,
334            Arc::clone(&self.local_filesystem),
335        )?;
336
337        self.buffers.insert(buffer_id, state);
338        self.event_logs
339            .insert(buffer_id, crate::model::event::EventLog::new());
340
341        // Create metadata
342        let metadata =
343            super::types::BufferMetadata::with_file(path.to_path_buf(), &self.working_dir);
344        self.buffer_metadata.insert(buffer_id, metadata);
345
346        // Add to active split's tabs
347        let active_split = self.split_manager.active_split();
348        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
349            view_state.add_buffer(buffer_id);
350            view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
351        }
352
353        self.set_active_buffer(buffer_id);
354
355        let display_name = path.display().to_string();
356        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
357
358        Ok(buffer_id)
359    }
360
361    /// Restore global file state (cursor and scroll position) for a newly opened file
362    ///
363    /// This looks up the file's saved state from the global file states store
364    /// and applies it to both the EditorState (cursor) and SplitViewState (viewport).
365    fn restore_global_file_state(&mut self, buffer_id: BufferId, path: &Path, split_id: SplitId) {
366        use crate::session::PersistedFileSession;
367
368        // Load the per-file session for this path (lazy load from disk)
369        let file_state = match PersistedFileSession::load(path) {
370            Some(state) => state,
371            None => return, // No saved state for this file
372        };
373
374        // Get the buffer to validate positions
375        let max_pos = match self.buffers.get(&buffer_id) {
376            Some(buffer) => buffer.buffer.len(),
377            None => return,
378        };
379
380        // Apply cursor position to EditorState (authoritative cursor)
381        if let Some(editor_state) = self.buffers.get_mut(&buffer_id) {
382            let cursor_pos = file_state.cursor.position.min(max_pos);
383            editor_state.cursors.primary_mut().position = cursor_pos;
384            editor_state.cursors.primary_mut().anchor =
385                file_state.cursor.anchor.map(|a| a.min(max_pos));
386        }
387
388        // Apply viewport (scroll) state to SplitViewState
389        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
390            view_state.viewport.top_byte = file_state.scroll.top_byte;
391            view_state.viewport.left_column = file_state.scroll.left_column;
392        }
393    }
394
395    /// Save file state when a buffer is closed (for per-file session persistence)
396    fn save_file_state_on_close(&self, buffer_id: BufferId) {
397        use crate::session::{
398            PersistedFileSession, SerializedCursor, SerializedFileState, SerializedScroll,
399        };
400
401        // Get the file path for this buffer
402        let abs_path = match self.buffer_metadata.get(&buffer_id) {
403            Some(metadata) => match metadata.file_path() {
404                Some(path) => path.to_path_buf(),
405                None => return, // Not a file buffer
406            },
407            None => return,
408        };
409
410        // Find a split that has this buffer open to get the view state
411        let view_state = self
412            .split_view_states
413            .values()
414            .find(|vs| vs.has_buffer(buffer_id));
415
416        let view_state = match view_state {
417            Some(vs) => vs,
418            None => return, // No split has this buffer
419        };
420
421        // Capture the current state
422        let primary_cursor = view_state.cursors.primary();
423        let file_state = SerializedFileState {
424            cursor: SerializedCursor {
425                position: primary_cursor.position,
426                anchor: primary_cursor.anchor,
427                sticky_column: primary_cursor.sticky_column,
428            },
429            additional_cursors: view_state
430                .cursors
431                .iter()
432                .skip(1)
433                .map(|(_, cursor)| SerializedCursor {
434                    position: cursor.position,
435                    anchor: cursor.anchor,
436                    sticky_column: cursor.sticky_column,
437                })
438                .collect(),
439            scroll: SerializedScroll {
440                top_byte: view_state.viewport.top_byte,
441                top_view_line_offset: view_state.viewport.top_view_line_offset,
442                left_column: view_state.viewport.left_column,
443            },
444        };
445
446        // Save to disk
447        PersistedFileSession::save(&abs_path, file_state);
448        tracing::debug!("Saved file state on close for {:?}", abs_path);
449    }
450
451    /// Navigate to a specific line and column in the active buffer.
452    ///
453    /// Line and column are 1-indexed (matching typical editor conventions).
454    /// If the line is out of bounds, navigates to the last line.
455    /// If the column is out of bounds, navigates to the end of the line.
456    pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
457        if line == 0 {
458            return; // Line numbers are 1-indexed
459        }
460
461        let buffer_id = self.active_buffer();
462        let estimated_line_length = self.config.editor.estimated_line_length;
463
464        if let Some(state) = self.buffers.get(&buffer_id) {
465            let cursor_id = state.cursors.primary_id();
466            let old_position = state.cursors.primary().position;
467            let old_anchor = state.cursors.primary().anchor;
468            let old_sticky_column = state.cursors.primary().sticky_column;
469            let is_large_file = state.buffer.line_count().is_none();
470            let buffer_len = state.buffer.len();
471
472            // Convert 1-indexed line to 0-indexed
473            let target_line = line.saturating_sub(1);
474            // Column is also 1-indexed, convert to 0-indexed
475            let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
476
477            let position = if is_large_file {
478                // Large file mode: estimate byte offset based on line number
479                let estimated_offset = target_line * estimated_line_length;
480                let clamped_offset = estimated_offset.min(buffer_len);
481
482                // Use LineIterator to find the actual line start at the estimated position
483                if let Some(state) = self.buffers.get_mut(&buffer_id) {
484                    let iter = state
485                        .buffer
486                        .line_iterator(clamped_offset, estimated_line_length);
487                    let line_start = iter.current_position();
488                    // Add column offset, clamped to buffer length
489                    (line_start + target_col).min(buffer_len)
490                } else {
491                    clamped_offset
492                }
493            } else {
494                // Small file mode: use exact line position
495                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
496                let actual_line = target_line.min(max_line);
497                state.buffer.line_col_to_position(actual_line, target_col)
498            };
499
500            let event = Event::MoveCursor {
501                cursor_id,
502                old_position,
503                new_position: position,
504                old_anchor,
505                new_anchor: None,
506                old_sticky_column,
507                new_sticky_column: target_col,
508            };
509
510            if let Some(state) = self.buffers.get_mut(&buffer_id) {
511                state.apply(&event);
512            }
513        }
514    }
515
516    /// Create a new empty buffer
517    pub fn new_buffer(&mut self) -> BufferId {
518        // Save current position before switching to new buffer
519        self.position_history.commit_pending_movement();
520
521        // Explicitly record current position before switching
522        let current_state = self.active_state();
523        let position = current_state.cursors.primary().position;
524        let anchor = current_state.cursors.primary().anchor;
525        self.position_history
526            .record_movement(self.active_buffer(), position, anchor);
527        self.position_history.commit_pending_movement();
528
529        let buffer_id = BufferId(self.next_buffer_id);
530        self.next_buffer_id += 1;
531
532        let mut state = EditorState::new(
533            self.terminal_width,
534            self.terminal_height,
535            self.config.editor.large_file_threshold_bytes as usize,
536            Arc::clone(&self.filesystem),
537        );
538        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
539        state
540            .margins
541            .set_line_numbers(self.config.editor.line_numbers);
542        // Set default line ending for new buffers from config
543        state
544            .buffer
545            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
546        self.buffers.insert(buffer_id, state);
547        self.event_logs
548            .insert(buffer_id, crate::model::event::EventLog::new());
549        self.buffer_metadata
550            .insert(buffer_id, crate::app::types::BufferMetadata::new());
551
552        // Apply line_wrap default from config to the active split
553        let active_split = self.split_manager.active_split();
554        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
555            view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
556        }
557
558        self.set_active_buffer(buffer_id);
559        self.status_message = Some(t!("buffer.new").to_string());
560
561        buffer_id
562    }
563
564    /// Create a new buffer from stdin content stored in a temp file
565    ///
566    /// Uses lazy chunk loading for efficient handling of large stdin inputs.
567    /// The buffer is unnamed (no file path for save) - saving will prompt for a filename.
568    /// The temp file path is preserved internally for lazy loading to work.
569    ///
570    /// # Arguments
571    /// * `temp_path` - Path to temp file where stdin content is being written
572    /// * `thread_handle` - Optional handle to background thread streaming stdin to temp file
573    pub fn open_stdin_buffer(
574        &mut self,
575        temp_path: &Path,
576        thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
577    ) -> AnyhowResult<BufferId> {
578        // Save current position before switching to new buffer
579        self.position_history.commit_pending_movement();
580
581        // Explicitly record current position before switching
582        let current_state = self.active_state();
583        let position = current_state.cursors.primary().position;
584        let anchor = current_state.cursors.primary().anchor;
585        self.position_history
586            .record_movement(self.active_buffer(), position, anchor);
587        self.position_history.commit_pending_movement();
588
589        // If the current buffer is empty and unmodified, replace it instead of creating a new one
590        // Note: Don't replace composite buffers (they appear empty but are special views)
591        let replace_current = {
592            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
593            !current_state.is_composite_buffer
594                && current_state.buffer.is_empty()
595                && !current_state.buffer.is_modified()
596                && current_state.buffer.file_path().is_none()
597        };
598
599        let buffer_id = if replace_current {
600            // Reuse the current empty buffer
601            self.active_buffer()
602        } else {
603            // Create new buffer ID
604            let id = BufferId(self.next_buffer_id);
605            self.next_buffer_id += 1;
606            id
607        };
608
609        // Get file size for status message before loading
610        let file_size = self.filesystem.metadata(temp_path)?.size as usize;
611
612        // Load from temp file using EditorState::from_file_with_languages
613        // This enables lazy chunk loading for large inputs (>100MB by default)
614        let mut state = EditorState::from_file_with_languages(
615            temp_path,
616            self.terminal_width,
617            self.terminal_height,
618            self.config.editor.large_file_threshold_bytes as usize,
619            &self.grammar_registry,
620            &self.config.languages,
621            Arc::clone(&self.filesystem),
622        )?;
623
624        // Clear the file path so the buffer is "unnamed" for save purposes
625        // The Unloaded chunks still reference the temp file for lazy loading
626        state.buffer.clear_file_path();
627        // Clear modified flag - content is "fresh" from stdin (vim behavior)
628        state.buffer.clear_modified();
629
630        // Set tab size from config
631        state.tab_size = self.config.editor.tab_size;
632
633        // Apply line_numbers default from config
634        state
635            .margins
636            .set_line_numbers(self.config.editor.line_numbers);
637
638        self.buffers.insert(buffer_id, state);
639        self.event_logs
640            .insert(buffer_id, crate::model::event::EventLog::new());
641
642        // Create metadata for this buffer (no file path)
643        let metadata =
644            super::types::BufferMetadata::new_unnamed(t!("stdin.display_name").to_string());
645        self.buffer_metadata.insert(buffer_id, metadata);
646
647        // Add buffer to the active split's tabs
648        let active_split = self.split_manager.active_split();
649        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
650            view_state.add_buffer(buffer_id);
651            // Apply line_wrap default from config
652            view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
653        }
654
655        self.set_active_buffer(buffer_id);
656
657        // Set up stdin streaming state for polling
658        // If no thread handle, it means data is already complete (testing scenario)
659        let complete = thread_handle.is_none();
660        self.stdin_streaming = Some(super::StdinStreamingState {
661            temp_path: temp_path.to_path_buf(),
662            buffer_id,
663            last_known_size: file_size,
664            complete,
665            thread_handle,
666        });
667
668        // Status will be updated by poll_stdin_streaming
669        self.status_message = Some(t!("stdin.streaming").to_string());
670
671        Ok(buffer_id)
672    }
673
674    /// Poll stdin streaming state and extend buffer if file grew.
675    /// Returns true if the status changed (needs render).
676    pub fn poll_stdin_streaming(&mut self) -> bool {
677        let Some(ref mut stream_state) = self.stdin_streaming else {
678            return false;
679        };
680
681        if stream_state.complete {
682            return false;
683        }
684
685        let mut changed = false;
686
687        // Check current file size
688        let current_size = self
689            .filesystem
690            .metadata(&stream_state.temp_path)
691            .map(|m| m.size as usize)
692            .unwrap_or(stream_state.last_known_size);
693
694        // If file grew, extend the buffer
695        if current_size > stream_state.last_known_size {
696            if let Some(editor_state) = self.buffers.get_mut(&stream_state.buffer_id) {
697                editor_state
698                    .buffer
699                    .extend_streaming(&stream_state.temp_path, current_size);
700            }
701            stream_state.last_known_size = current_size;
702
703            // Update status message with current progress
704            self.status_message =
705                Some(t!("stdin.streaming_bytes", bytes = current_size).to_string());
706            changed = true;
707        }
708
709        // Check if background thread has finished
710        let thread_finished = stream_state
711            .thread_handle
712            .as_ref()
713            .map(|h| h.is_finished())
714            .unwrap_or(true);
715
716        if thread_finished {
717            // Take ownership of handle to join it
718            if let Some(handle) = stream_state.thread_handle.take() {
719                match handle.join() {
720                    Ok(Ok(())) => {
721                        tracing::info!("Stdin streaming completed successfully");
722                    }
723                    Ok(Err(e)) => {
724                        tracing::warn!("Stdin streaming error: {}", e);
725                        self.status_message =
726                            Some(t!("stdin.read_error", error = e.to_string()).to_string());
727                    }
728                    Err(_) => {
729                        tracing::warn!("Stdin streaming thread panicked");
730                        self.status_message = Some(t!("stdin.read_error_panic").to_string());
731                    }
732                }
733            }
734            self.complete_stdin_streaming();
735            changed = true;
736        }
737
738        changed
739    }
740
741    /// Mark stdin streaming as complete.
742    /// Called when the background thread finishes.
743    pub fn complete_stdin_streaming(&mut self) {
744        if let Some(ref mut stream_state) = self.stdin_streaming {
745            stream_state.complete = true;
746
747            // Final poll to get any remaining data
748            let final_size = self
749                .filesystem
750                .metadata(&stream_state.temp_path)
751                .map(|m| m.size as usize)
752                .unwrap_or(stream_state.last_known_size);
753
754            if final_size > stream_state.last_known_size {
755                if let Some(editor_state) = self.buffers.get_mut(&stream_state.buffer_id) {
756                    editor_state
757                        .buffer
758                        .extend_streaming(&stream_state.temp_path, final_size);
759                }
760                stream_state.last_known_size = final_size;
761            }
762
763            self.status_message =
764                Some(t!("stdin.read_complete", bytes = stream_state.last_known_size).to_string());
765        }
766    }
767
768    /// Check if stdin streaming is active (not complete).
769    pub fn is_stdin_streaming(&self) -> bool {
770        self.stdin_streaming
771            .as_ref()
772            .map(|s| !s.complete)
773            .unwrap_or(false)
774    }
775
776    /// Create a new virtual buffer (not backed by a file)
777    ///
778    /// # Arguments
779    /// * `name` - Display name (e.g., "*Diagnostics*")
780    /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
781    /// * `read_only` - Whether the buffer should be read-only
782    ///
783    /// # Returns
784    /// The BufferId of the created virtual buffer
785    pub fn create_virtual_buffer(
786        &mut self,
787        name: String,
788        mode: String,
789        read_only: bool,
790    ) -> BufferId {
791        let buffer_id = BufferId(self.next_buffer_id);
792        self.next_buffer_id += 1;
793
794        let mut state = EditorState::new(
795            self.terminal_width,
796            self.terminal_height,
797            self.config.editor.large_file_threshold_bytes as usize,
798            Arc::clone(&self.filesystem),
799        );
800        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
801
802        // Set syntax highlighting based on buffer name (e.g., "*OURS*.c" will get C highlighting)
803        state.set_language_from_name(&name, &self.grammar_registry);
804
805        // Apply line_numbers default from config
806        state
807            .margins
808            .set_line_numbers(self.config.editor.line_numbers);
809
810        self.buffers.insert(buffer_id, state);
811        self.event_logs
812            .insert(buffer_id, crate::model::event::EventLog::new());
813
814        // Set virtual buffer metadata
815        let metadata = super::types::BufferMetadata::virtual_buffer(name, mode, read_only);
816        self.buffer_metadata.insert(buffer_id, metadata);
817
818        // Add buffer to the active split's open_buffers (tabs)
819        let active_split = self.split_manager.active_split();
820        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
821            view_state.add_buffer(buffer_id);
822        } else {
823            // Create view state if it doesn't exist
824            let mut view_state =
825                SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buffer_id);
826            view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
827            self.split_view_states.insert(active_split, view_state);
828        }
829
830        buffer_id
831    }
832
833    /// Set the content of a virtual buffer with text properties
834    ///
835    /// # Arguments
836    /// * `buffer_id` - The virtual buffer to update
837    /// * `entries` - Text entries with embedded properties
838    pub fn set_virtual_buffer_content(
839        &mut self,
840        buffer_id: BufferId,
841        entries: Vec<crate::primitives::text_property::TextPropertyEntry>,
842    ) -> Result<(), String> {
843        let state = self
844            .buffers
845            .get_mut(&buffer_id)
846            .ok_or_else(|| "Buffer not found".to_string())?;
847
848        // Save current cursor position to preserve it after content update
849        let old_cursor_pos = state.cursors.primary().position;
850
851        // Build text and properties from entries
852        let (text, properties) =
853            crate::primitives::text_property::TextPropertyManager::from_entries(entries);
854
855        // Replace buffer content
856        let current_len = state.buffer.len();
857        if current_len > 0 {
858            state.buffer.delete_bytes(0, current_len);
859        }
860        state.buffer.insert(0, &text);
861
862        // Clear modified flag since this is virtual buffer content setting, not user edits
863        state.buffer.clear_modified();
864
865        // Set text properties
866        state.text_properties = properties;
867
868        // Preserve cursor position (clamped to new content length and snapped to char boundary)
869        let new_len = state.buffer.len();
870        let clamped_pos = old_cursor_pos.min(new_len);
871        // Ensure cursor is at a valid UTF-8 character boundary (without moving if already valid)
872        let new_cursor_pos = state.buffer.snap_to_char_boundary(clamped_pos);
873        state.cursors.primary_mut().position = new_cursor_pos;
874        state.cursors.primary_mut().anchor = None;
875
876        Ok(())
877    }
878
879    /// Open the built-in help manual in a read-only buffer
880    ///
881    /// If a help manual buffer already exists, switch to it instead of creating a new one.
882    pub fn open_help_manual(&mut self) {
883        // Check if help buffer already exists
884        let existing_buffer = self
885            .buffer_metadata
886            .iter()
887            .find(|(_, m)| m.display_name == help::HELP_MANUAL_BUFFER_NAME)
888            .map(|(id, _)| *id);
889
890        if let Some(buffer_id) = existing_buffer {
891            // Switch to existing help buffer
892            self.set_active_buffer(buffer_id);
893            return;
894        }
895
896        // Create new help buffer with "special" mode (has 'q' to close)
897        let buffer_id = self.create_virtual_buffer(
898            help::HELP_MANUAL_BUFFER_NAME.to_string(),
899            "special".to_string(),
900            true,
901        );
902
903        // Set the content
904        if let Some(state) = self.buffers.get_mut(&buffer_id) {
905            state.buffer.insert(0, help::HELP_MANUAL_CONTENT);
906            state.buffer.clear_modified();
907            state.editing_disabled = true;
908
909            // Disable line numbers for cleaner display
910            state.margins.set_line_numbers(false);
911        }
912
913        self.set_active_buffer(buffer_id);
914    }
915
916    /// Open the keyboard shortcuts viewer in a read-only buffer
917    ///
918    /// If a keyboard shortcuts buffer already exists, switch to it instead of creating a new one.
919    /// The shortcuts are dynamically generated from the current keybindings configuration.
920    pub fn open_keyboard_shortcuts(&mut self) {
921        // Check if keyboard shortcuts buffer already exists
922        let existing_buffer = self
923            .buffer_metadata
924            .iter()
925            .find(|(_, m)| m.display_name == help::KEYBOARD_SHORTCUTS_BUFFER_NAME)
926            .map(|(id, _)| *id);
927
928        if let Some(buffer_id) = existing_buffer {
929            // Switch to existing buffer
930            self.set_active_buffer(buffer_id);
931            return;
932        }
933
934        // Get all keybindings
935        let bindings = self.keybindings.get_all_bindings();
936
937        // Format the keybindings as readable text
938        let mut content = String::from("Keyboard Shortcuts\n");
939        content.push_str("==================\n\n");
940        content.push_str("Press 'q' to close this buffer.\n\n");
941
942        // Group bindings by context (Normal, Prompt, etc.)
943        let mut current_context = String::new();
944        for (key, action) in &bindings {
945            // Check if action starts with a context prefix like "[Prompt] "
946            let (context, action_name) = if let Some(bracket_end) = action.find("] ") {
947                let ctx = &action[1..bracket_end];
948                let name = &action[bracket_end + 2..];
949                (ctx.to_string(), name.to_string())
950            } else {
951                ("Normal".to_string(), action.clone())
952            };
953
954            // Print context header when it changes
955            if context != current_context {
956                if !current_context.is_empty() {
957                    content.push('\n');
958                }
959                content.push_str(&format!("── {} Mode ──\n\n", context));
960                current_context = context;
961            }
962
963            // Format: "  Ctrl+S          Save"
964            content.push_str(&format!("  {:20} {}\n", key, action_name));
965        }
966
967        // Create new keyboard shortcuts buffer with "special" mode (has 'q' to close)
968        let buffer_id = self.create_virtual_buffer(
969            help::KEYBOARD_SHORTCUTS_BUFFER_NAME.to_string(),
970            "special".to_string(),
971            true,
972        );
973
974        // Set the content
975        if let Some(state) = self.buffers.get_mut(&buffer_id) {
976            state.buffer.insert(0, &content);
977            state.buffer.clear_modified();
978            state.editing_disabled = true;
979
980            // Disable line numbers for cleaner display
981            state.margins.set_line_numbers(false);
982        }
983
984        self.set_active_buffer(buffer_id);
985    }
986
987    /// Show warnings by opening the warning log file directly
988    ///
989    /// If there are no warnings, shows a brief status message.
990    /// Otherwise, opens the warning log file for the user to view.
991    pub fn show_warnings_popup(&mut self) {
992        if !self.warning_domains.has_any_warnings() {
993            self.status_message = Some(t!("warnings.none").to_string());
994            return;
995        }
996
997        // Open the warning log file directly
998        self.open_warning_log();
999    }
1000
1001    /// Show LSP status - opens the warning log file if there are LSP warnings,
1002    /// otherwise shows a brief status message.
1003    pub fn show_lsp_status_popup(&mut self) {
1004        let has_error = self.warning_domains.lsp.level() == crate::app::WarningLevel::Error;
1005
1006        // Use the language from the LSP error state if available, otherwise detect from buffer.
1007        // This ensures clicking the status indicator works regardless of which buffer is focused.
1008        let language = self
1009            .warning_domains
1010            .lsp
1011            .language
1012            .clone()
1013            .unwrap_or_else(|| {
1014                self.buffer_metadata
1015                    .get(&self.active_buffer())
1016                    .and_then(|m| m.file_path())
1017                    .and_then(|path| detect_language(path, &self.config.languages))
1018                    .unwrap_or_else(|| "unknown".to_string())
1019            });
1020
1021        tracing::info!(
1022            "show_lsp_status_popup: language={}, has_error={}, has_warnings={}",
1023            language,
1024            has_error,
1025            self.warning_domains.lsp.has_warnings()
1026        );
1027
1028        // Fire the LspStatusClicked hook for plugins
1029        self.plugin_manager.run_hook(
1030            "lsp_status_clicked",
1031            crate::services::plugins::hooks::HookArgs::LspStatusClicked {
1032                language: language.clone(),
1033                has_error,
1034            },
1035        );
1036        tracing::info!("show_lsp_status_popup: hook fired");
1037
1038        if !self.warning_domains.lsp.has_warnings() {
1039            if self.lsp_status.is_empty() {
1040                self.status_message = Some(t!("lsp.no_server_active").to_string());
1041            } else {
1042                self.status_message = Some(t!("lsp.status", status = &self.lsp_status).to_string());
1043            }
1044            return;
1045        }
1046
1047        // If there's an LSP error AND a plugin is handling the status click, don't open the
1048        // warning log which would switch focus and break language detection for subsequent clicks.
1049        // Only suppress if a plugin has registered to handle the hook.
1050        if has_error && self.plugin_manager.has_hook_handlers("lsp_status_clicked") {
1051            tracing::info!(
1052                "show_lsp_status_popup: has_error=true and plugin registered, skipping warning log"
1053            );
1054            return;
1055        }
1056
1057        // Open the warning log file directly (same as warnings popup)
1058        self.open_warning_log();
1059    }
1060
1061    /// Get text properties at the cursor position in the active buffer
1062    pub fn get_text_properties_at_cursor(
1063        &self,
1064    ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
1065        let state = self.buffers.get(&self.active_buffer())?;
1066        let cursor_pos = state.cursors.primary().position;
1067        Some(state.text_properties.get_at(cursor_pos))
1068    }
1069
1070    /// Close the given buffer
1071    pub fn close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
1072        // Check for unsaved changes
1073        if let Some(state) = self.buffers.get(&id) {
1074            if state.buffer.is_modified() {
1075                return Err(anyhow::anyhow!("Buffer has unsaved changes"));
1076            }
1077        }
1078        self.close_buffer_internal(id)
1079    }
1080
1081    /// Force close the given buffer without checking for unsaved changes
1082    /// Use this when the user has already confirmed they want to discard changes
1083    pub fn force_close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
1084        self.close_buffer_internal(id)
1085    }
1086
1087    /// Internal helper to close a buffer (shared by close_buffer and force_close_buffer)
1088    fn close_buffer_internal(&mut self, id: BufferId) -> anyhow::Result<()> {
1089        // Save file state before closing (for per-file session persistence)
1090        self.save_file_state_on_close(id);
1091
1092        // If closing a terminal buffer, clean up terminal-related data structures
1093        if let Some(terminal_id) = self.terminal_buffers.remove(&id) {
1094            // Close the terminal process
1095            self.terminal_manager.close(terminal_id);
1096
1097            // Clean up backing/rendering file
1098            let backing_file = self.terminal_backing_files.remove(&terminal_id);
1099            if let Some(ref path) = backing_file {
1100                let _ = self.filesystem.remove_file(path);
1101            }
1102            // Clean up raw log file
1103            if let Some(log_file) = self.terminal_log_files.remove(&terminal_id) {
1104                if backing_file.as_ref() != Some(&log_file) {
1105                    let _ = self.filesystem.remove_file(&log_file);
1106                }
1107            }
1108
1109            // Remove from terminal_mode_resume to prevent stale entries
1110            self.terminal_mode_resume.remove(&id);
1111
1112            // Exit terminal mode if we were in it
1113            if self.terminal_mode {
1114                self.terminal_mode = false;
1115                self.key_context = crate::input::keybindings::KeyContext::Normal;
1116            }
1117        }
1118
1119        // Find a replacement buffer, preferring the most recently focused one
1120        // First try focus history, then fall back to any visible buffer
1121        let active_split = self.split_manager.active_split();
1122        let replacement_from_history = self.split_view_states.get(&active_split).and_then(|vs| {
1123            // Find the most recently focused buffer that's still open and visible
1124            vs.focus_history
1125                .iter()
1126                .rev()
1127                .find(|&&bid| {
1128                    bid != id
1129                        && self.buffers.contains_key(&bid)
1130                        && !self
1131                            .buffer_metadata
1132                            .get(&bid)
1133                            .map(|m| m.hidden_from_tabs)
1134                            .unwrap_or(false)
1135                })
1136                .copied()
1137        });
1138
1139        // Fall back to any visible buffer if no history match
1140        let visible_replacement = replacement_from_history.or_else(|| {
1141            self.buffers
1142                .keys()
1143                .find(|&&bid| {
1144                    bid != id
1145                        && !self
1146                            .buffer_metadata
1147                            .get(&bid)
1148                            .map(|m| m.hidden_from_tabs)
1149                            .unwrap_or(false)
1150                })
1151                .copied()
1152        });
1153
1154        let is_last_visible_buffer = visible_replacement.is_none();
1155        let replacement_buffer = if is_last_visible_buffer {
1156            self.new_buffer()
1157        } else {
1158            visible_replacement.unwrap()
1159        };
1160
1161        // Switch to replacement buffer BEFORE updating splits.
1162        // This is important because set_active_buffer returns early if the buffer
1163        // is already active, and updating splits changes what active_buffer() returns.
1164        // We need set_active_buffer to run its terminal_mode_resume logic.
1165        if self.active_buffer() == id {
1166            self.set_active_buffer(replacement_buffer);
1167        }
1168
1169        // Update all splits that are showing this buffer to show the replacement
1170        let splits_to_update = self.split_manager.splits_for_buffer(id);
1171        for split_id in splits_to_update {
1172            let _ = self
1173                .split_manager
1174                .set_split_buffer(split_id, replacement_buffer);
1175        }
1176
1177        self.buffers.remove(&id);
1178        self.event_logs.remove(&id);
1179        self.seen_byte_ranges.remove(&id);
1180        self.buffer_metadata.remove(&id);
1181        if let Some((request_id, _, _)) = self.semantic_tokens_in_flight.remove(&id) {
1182            self.pending_semantic_token_requests.remove(&request_id);
1183        }
1184        if let Some((request_id, _, _, _)) = self.semantic_tokens_range_in_flight.remove(&id) {
1185            self.pending_semantic_token_range_requests
1186                .remove(&request_id);
1187        }
1188        self.semantic_tokens_range_last_request.remove(&id);
1189        self.semantic_tokens_range_applied.remove(&id);
1190        self.semantic_tokens_full_debounce.remove(&id);
1191
1192        // Remove buffer from panel_ids mapping if it was a panel buffer
1193        // This prevents stale entries when the same panel_id is reused later
1194        self.panel_ids.retain(|_, &mut buf_id| buf_id != id);
1195
1196        // Remove buffer from all splits' open_buffers lists and focus history
1197        for view_state in self.split_view_states.values_mut() {
1198            view_state.remove_buffer(id);
1199            view_state.remove_from_history(id);
1200        }
1201
1202        // If this was the last visible buffer, focus file explorer
1203        if is_last_visible_buffer {
1204            self.focus_file_explorer();
1205        }
1206
1207        Ok(())
1208    }
1209
1210    /// Switch to the given buffer
1211    pub fn switch_buffer(&mut self, id: BufferId) {
1212        if self.buffers.contains_key(&id) && id != self.active_buffer() {
1213            // Save current position before switching buffers
1214            self.position_history.commit_pending_movement();
1215
1216            // Also explicitly record current position (in case there was no pending movement)
1217            let current_state = self.active_state();
1218            let position = current_state.cursors.primary().position;
1219            let anchor = current_state.cursors.primary().anchor;
1220            self.position_history
1221                .record_movement(self.active_buffer(), position, anchor);
1222            self.position_history.commit_pending_movement();
1223
1224            self.set_active_buffer(id);
1225        }
1226    }
1227
1228    /// Close the current tab in the current split view.
1229    /// If the tab is the last viewport of the underlying buffer, do the same as close_buffer
1230    /// (including triggering the save/discard prompt for modified buffers).
1231    pub fn close_tab(&mut self) {
1232        let buffer_id = self.active_buffer();
1233        let active_split = self.split_manager.active_split();
1234
1235        // Count how many splits have this buffer in their open_buffers
1236        let buffer_in_other_splits = self
1237            .split_view_states
1238            .iter()
1239            .filter(|(&split_id, view_state)| {
1240                split_id != active_split && view_state.has_buffer(buffer_id)
1241            })
1242            .count();
1243
1244        // Get current split's open buffers
1245        let current_split_tabs = self
1246            .split_view_states
1247            .get(&active_split)
1248            .map(|vs| vs.open_buffers.clone())
1249            .unwrap_or_default();
1250
1251        // If this is the only tab in this split and there are no other splits with this buffer,
1252        // this is the last viewport - behave like close_buffer
1253        let is_last_viewport = buffer_in_other_splits == 0;
1254
1255        if is_last_viewport {
1256            // If this is the only buffer in this split AND there are other splits,
1257            // close the split instead of the buffer (don't create an empty replacement)
1258            let has_other_splits = self.split_manager.root().count_leaves() > 1;
1259            if current_split_tabs.len() <= 1 && has_other_splits {
1260                // Check for unsaved changes first
1261                if self.active_state().buffer.is_modified() {
1262                    let name = self.get_buffer_display_name(buffer_id);
1263                    let save_key = t!("prompt.key.save").to_string();
1264                    let discard_key = t!("prompt.key.discard").to_string();
1265                    let cancel_key = t!("prompt.key.cancel").to_string();
1266                    self.start_prompt(
1267                        t!(
1268                            "prompt.buffer_modified",
1269                            name = name,
1270                            save_key = save_key,
1271                            discard_key = discard_key,
1272                            cancel_key = cancel_key
1273                        )
1274                        .to_string(),
1275                        PromptType::ConfirmCloseBuffer { buffer_id },
1276                    );
1277                    return;
1278                }
1279                // Close the buffer first, then the split
1280                let _ = self.close_buffer(buffer_id);
1281                self.close_active_split();
1282                return;
1283            }
1284
1285            // Last viewport of this buffer - close the buffer entirely
1286            if self.active_state().buffer.is_modified() {
1287                // Buffer has unsaved changes - prompt for confirmation
1288                let name = self.get_buffer_display_name(buffer_id);
1289                let save_key = t!("prompt.key.save").to_string();
1290                let discard_key = t!("prompt.key.discard").to_string();
1291                let cancel_key = t!("prompt.key.cancel").to_string();
1292                self.start_prompt(
1293                    t!(
1294                        "prompt.buffer_modified",
1295                        name = name,
1296                        save_key = save_key,
1297                        discard_key = discard_key,
1298                        cancel_key = cancel_key
1299                    )
1300                    .to_string(),
1301                    PromptType::ConfirmCloseBuffer { buffer_id },
1302                );
1303            } else if let Err(e) = self.close_buffer(buffer_id) {
1304                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1305            } else {
1306                self.set_status_message(t!("buffer.tab_closed").to_string());
1307            }
1308        } else {
1309            // There are other viewports of this buffer - just remove from current split's tabs
1310            if current_split_tabs.len() <= 1 {
1311                // This is the only tab in this split - close the split
1312                // If we're closing a terminal buffer while in terminal mode, exit terminal mode
1313                if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1314                    self.terminal_mode = false;
1315                    self.key_context = crate::input::keybindings::KeyContext::Normal;
1316                }
1317                self.close_active_split();
1318                return;
1319            }
1320
1321            // Find replacement buffer for this split
1322            let current_idx = current_split_tabs
1323                .iter()
1324                .position(|&id| id == buffer_id)
1325                .unwrap_or(0);
1326            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
1327            let replacement_buffer = current_split_tabs[replacement_idx];
1328
1329            // If we're closing a terminal buffer while in terminal mode, exit terminal mode
1330            if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1331                self.terminal_mode = false;
1332                self.key_context = crate::input::keybindings::KeyContext::Normal;
1333            }
1334
1335            // Remove buffer from this split's tabs
1336            if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1337                view_state.remove_buffer(buffer_id);
1338            }
1339
1340            // Update the split to show the replacement buffer
1341            let _ = self
1342                .split_manager
1343                .set_split_buffer(active_split, replacement_buffer);
1344
1345            self.set_status_message(t!("buffer.tab_closed").to_string());
1346        }
1347    }
1348
1349    /// Close a specific tab (buffer) in a specific split.
1350    /// Used by mouse click handler on tab close button.
1351    /// Returns true if the tab was closed without needing a prompt.
1352    pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: SplitId) -> bool {
1353        // If closing a terminal buffer while in terminal mode, exit terminal mode
1354        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1355            self.terminal_mode = false;
1356            self.key_context = crate::input::keybindings::KeyContext::Normal;
1357        }
1358
1359        // Count how many splits have this buffer in their open_buffers
1360        let buffer_in_other_splits = self
1361            .split_view_states
1362            .iter()
1363            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
1364            .count();
1365
1366        // Get the split's open buffers
1367        let split_tabs = self
1368            .split_view_states
1369            .get(&split_id)
1370            .map(|vs| vs.open_buffers.clone())
1371            .unwrap_or_default();
1372
1373        let is_last_viewport = buffer_in_other_splits == 0;
1374
1375        if is_last_viewport {
1376            // Last viewport of this buffer - need to close buffer entirely
1377            if let Some(state) = self.buffers.get(&buffer_id) {
1378                if state.buffer.is_modified() {
1379                    // Buffer has unsaved changes - prompt for confirmation
1380                    let name = self.get_buffer_display_name(buffer_id);
1381                    let save_key = t!("prompt.key.save").to_string();
1382                    let discard_key = t!("prompt.key.discard").to_string();
1383                    let cancel_key = t!("prompt.key.cancel").to_string();
1384                    self.start_prompt(
1385                        t!(
1386                            "prompt.buffer_modified",
1387                            name = name,
1388                            save_key = save_key,
1389                            discard_key = discard_key,
1390                            cancel_key = cancel_key
1391                        )
1392                        .to_string(),
1393                        PromptType::ConfirmCloseBuffer { buffer_id },
1394                    );
1395                    return false;
1396                }
1397            }
1398            if let Err(e) = self.close_buffer(buffer_id) {
1399                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1400            } else {
1401                self.set_status_message(t!("buffer.tab_closed").to_string());
1402            }
1403        } else {
1404            // There are other viewports of this buffer - just remove from this split's tabs
1405            if split_tabs.len() <= 1 {
1406                // This is the only tab in this split - close the split
1407                self.handle_close_split(split_id);
1408                return true;
1409            }
1410
1411            // Find replacement buffer for this split
1412            let current_idx = split_tabs
1413                .iter()
1414                .position(|&id| id == buffer_id)
1415                .unwrap_or(0);
1416            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
1417            let replacement_buffer = split_tabs[replacement_idx];
1418
1419            // Remove buffer from this split's tabs
1420            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1421                view_state.remove_buffer(buffer_id);
1422            }
1423
1424            // Update the split to show the replacement buffer
1425            let _ = self
1426                .split_manager
1427                .set_split_buffer(split_id, replacement_buffer);
1428
1429            self.set_status_message(t!("buffer.tab_closed").to_string());
1430        }
1431        true
1432    }
1433
1434    /// Close all other tabs in a split, keeping only the specified buffer
1435    pub fn close_other_tabs_in_split(&mut self, keep_buffer_id: BufferId, split_id: SplitId) {
1436        // Get the split's open buffers
1437        let split_tabs = self
1438            .split_view_states
1439            .get(&split_id)
1440            .map(|vs| vs.open_buffers.clone())
1441            .unwrap_or_default();
1442
1443        // Close all tabs except the one we want to keep
1444        let tabs_to_close: Vec<_> = split_tabs
1445            .iter()
1446            .filter(|&&id| id != keep_buffer_id)
1447            .copied()
1448            .collect();
1449
1450        let mut closed = 0;
1451        let mut skipped_modified = 0;
1452        for buffer_id in tabs_to_close {
1453            if self.close_tab_in_split_silent(buffer_id, split_id) {
1454                closed += 1;
1455            } else {
1456                skipped_modified += 1;
1457            }
1458        }
1459
1460        // Make sure the kept buffer is active
1461        let _ = self
1462            .split_manager
1463            .set_split_buffer(split_id, keep_buffer_id);
1464
1465        self.set_batch_close_status_message(closed, skipped_modified);
1466    }
1467
1468    /// Close tabs to the right of the specified buffer in a split
1469    pub fn close_tabs_to_right_in_split(&mut self, buffer_id: BufferId, split_id: SplitId) {
1470        // Get the split's open buffers
1471        let split_tabs = self
1472            .split_view_states
1473            .get(&split_id)
1474            .map(|vs| vs.open_buffers.clone())
1475            .unwrap_or_default();
1476
1477        // Find the index of the target buffer
1478        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
1479            return;
1480        };
1481
1482        // Close all tabs after the target
1483        let tabs_to_close: Vec<_> = split_tabs.iter().skip(target_idx + 1).copied().collect();
1484
1485        let mut closed = 0;
1486        let mut skipped_modified = 0;
1487        for buf_id in tabs_to_close {
1488            if self.close_tab_in_split_silent(buf_id, split_id) {
1489                closed += 1;
1490            } else {
1491                skipped_modified += 1;
1492            }
1493        }
1494
1495        self.set_batch_close_status_message(closed, skipped_modified);
1496    }
1497
1498    /// Close tabs to the left of the specified buffer in a split
1499    pub fn close_tabs_to_left_in_split(&mut self, buffer_id: BufferId, split_id: SplitId) {
1500        // Get the split's open buffers
1501        let split_tabs = self
1502            .split_view_states
1503            .get(&split_id)
1504            .map(|vs| vs.open_buffers.clone())
1505            .unwrap_or_default();
1506
1507        // Find the index of the target buffer
1508        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
1509            return;
1510        };
1511
1512        // Close all tabs before the target
1513        let tabs_to_close: Vec<_> = split_tabs.iter().take(target_idx).copied().collect();
1514
1515        let mut closed = 0;
1516        let mut skipped_modified = 0;
1517        for buf_id in tabs_to_close {
1518            if self.close_tab_in_split_silent(buf_id, split_id) {
1519                closed += 1;
1520            } else {
1521                skipped_modified += 1;
1522            }
1523        }
1524
1525        self.set_batch_close_status_message(closed, skipped_modified);
1526    }
1527
1528    /// Close all tabs in a split
1529    pub fn close_all_tabs_in_split(&mut self, split_id: SplitId) {
1530        // Get the split's open buffers
1531        let split_tabs = self
1532            .split_view_states
1533            .get(&split_id)
1534            .map(|vs| vs.open_buffers.clone())
1535            .unwrap_or_default();
1536
1537        let mut closed = 0;
1538        let mut skipped_modified = 0;
1539
1540        // Close all tabs (this will eventually close the split when empty)
1541        for buffer_id in split_tabs {
1542            if self.close_tab_in_split_silent(buffer_id, split_id) {
1543                closed += 1;
1544            } else {
1545                skipped_modified += 1;
1546            }
1547        }
1548
1549        self.set_batch_close_status_message(closed, skipped_modified);
1550    }
1551
1552    /// Set status message for batch close operations
1553    fn set_batch_close_status_message(&mut self, closed: usize, skipped_modified: usize) {
1554        let message = match (closed, skipped_modified) {
1555            (0, 0) => t!("buffer.no_tabs_to_close").to_string(),
1556            (0, n) => t!("buffer.skipped_modified", count = n).to_string(),
1557            (n, 0) => t!("buffer.closed_tabs", count = n).to_string(),
1558            (c, s) => t!("buffer.closed_tabs_skipped", closed = c, skipped = s).to_string(),
1559        };
1560        self.set_status_message(message);
1561    }
1562
1563    /// Close a tab silently (without setting status message)
1564    /// Used internally by batch close operations
1565    /// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
1566    fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: SplitId) -> bool {
1567        // If closing a terminal buffer while in terminal mode, exit terminal mode
1568        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
1569            self.terminal_mode = false;
1570            self.key_context = crate::input::keybindings::KeyContext::Normal;
1571        }
1572
1573        // Count how many splits have this buffer in their open_buffers
1574        let buffer_in_other_splits = self
1575            .split_view_states
1576            .iter()
1577            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
1578            .count();
1579
1580        // Get the split's open buffers
1581        let split_tabs = self
1582            .split_view_states
1583            .get(&split_id)
1584            .map(|vs| vs.open_buffers.clone())
1585            .unwrap_or_default();
1586
1587        let is_last_viewport = buffer_in_other_splits == 0;
1588
1589        if is_last_viewport {
1590            // Last viewport of this buffer - need to close buffer entirely
1591            // Skip modified buffers to avoid prompting during batch operations
1592            if let Some(state) = self.buffers.get(&buffer_id) {
1593                if state.buffer.is_modified() {
1594                    // Skip modified buffers - don't close them
1595                    return false;
1596                }
1597            }
1598            let _ = self.close_buffer(buffer_id);
1599            true
1600        } else {
1601            // There are other viewports of this buffer - just remove from this split's tabs
1602            if split_tabs.len() <= 1 {
1603                // This is the only tab in this split - close the split
1604                self.handle_close_split(split_id);
1605                return true;
1606            }
1607
1608            // Find replacement buffer for this split
1609            let current_idx = split_tabs
1610                .iter()
1611                .position(|&id| id == buffer_id)
1612                .unwrap_or(0);
1613            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
1614            let replacement_buffer = split_tabs.get(replacement_idx).copied();
1615
1616            // Remove buffer from this split's tabs
1617            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1618                view_state.remove_buffer(buffer_id);
1619            }
1620
1621            // Update the split to show the replacement buffer
1622            if let Some(replacement) = replacement_buffer {
1623                let _ = self.split_manager.set_split_buffer(split_id, replacement);
1624            }
1625            true
1626        }
1627    }
1628
1629    /// Get visible (non-hidden) buffers for the current split.
1630    /// This filters out buffers with hidden_from_tabs=true.
1631    fn visible_buffers_for_active_split(&self) -> Vec<BufferId> {
1632        let active_split = self.split_manager.active_split();
1633        if let Some(view_state) = self.split_view_states.get(&active_split) {
1634            view_state
1635                .open_buffers
1636                .iter()
1637                .copied()
1638                .filter(|id| {
1639                    !self
1640                        .buffer_metadata
1641                        .get(id)
1642                        .map(|m| m.hidden_from_tabs)
1643                        .unwrap_or(false)
1644                })
1645                .collect()
1646        } else {
1647            // Fallback to all visible buffers if no view state
1648            let mut all_ids: Vec<_> = self
1649                .buffers
1650                .keys()
1651                .copied()
1652                .filter(|id| {
1653                    !self
1654                        .buffer_metadata
1655                        .get(id)
1656                        .map(|m| m.hidden_from_tabs)
1657                        .unwrap_or(false)
1658                })
1659                .collect();
1660            all_ids.sort_by_key(|id| id.0);
1661            all_ids
1662        }
1663    }
1664
1665    /// Switch to next buffer in current split's tabs
1666    pub fn next_buffer(&mut self) {
1667        let ids = self.visible_buffers_for_active_split();
1668
1669        if ids.is_empty() {
1670            return;
1671        }
1672
1673        if let Some(idx) = ids.iter().position(|&id| id == self.active_buffer()) {
1674            let next_idx = (idx + 1) % ids.len();
1675            if ids[next_idx] != self.active_buffer() {
1676                // Save current position before switching
1677                self.position_history.commit_pending_movement();
1678
1679                // Also explicitly record current position
1680                let current_state = self.active_state();
1681                let position = current_state.cursors.primary().position;
1682                let anchor = current_state.cursors.primary().anchor;
1683                self.position_history
1684                    .record_movement(self.active_buffer(), position, anchor);
1685                self.position_history.commit_pending_movement();
1686
1687                self.set_active_buffer(ids[next_idx]);
1688            }
1689        }
1690    }
1691
1692    /// Switch to previous buffer in current split's tabs
1693    pub fn prev_buffer(&mut self) {
1694        let ids = self.visible_buffers_for_active_split();
1695
1696        if ids.is_empty() {
1697            return;
1698        }
1699
1700        if let Some(idx) = ids.iter().position(|&id| id == self.active_buffer()) {
1701            let prev_idx = if idx == 0 { ids.len() - 1 } else { idx - 1 };
1702            if ids[prev_idx] != self.active_buffer() {
1703                // Save current position before switching
1704                self.position_history.commit_pending_movement();
1705
1706                // Also explicitly record current position
1707                let current_state = self.active_state();
1708                let position = current_state.cursors.primary().position;
1709                let anchor = current_state.cursors.primary().anchor;
1710                self.position_history
1711                    .record_movement(self.active_buffer(), position, anchor);
1712                self.position_history.commit_pending_movement();
1713
1714                self.set_active_buffer(ids[prev_idx]);
1715            }
1716        }
1717    }
1718
1719    /// Navigate back in position history
1720    pub fn navigate_back(&mut self) {
1721        // Set flag to prevent recording this navigation movement
1722        self.in_navigation = true;
1723
1724        // Commit any pending movement
1725        self.position_history.commit_pending_movement();
1726
1727        // If we're at the end of history (haven't used back yet), save current position
1728        // so we can navigate forward to it later
1729        if self.position_history.can_go_back() && !self.position_history.can_go_forward() {
1730            let current_state = self.active_state();
1731            let position = current_state.cursors.primary().position;
1732            let anchor = current_state.cursors.primary().anchor;
1733            self.position_history
1734                .record_movement(self.active_buffer(), position, anchor);
1735            self.position_history.commit_pending_movement();
1736        }
1737
1738        // Navigate to the previous position
1739        if let Some(entry) = self.position_history.back() {
1740            let target_buffer = entry.buffer_id;
1741            let target_position = entry.position;
1742            let target_anchor = entry.anchor;
1743
1744            // Switch to the target buffer
1745            if self.buffers.contains_key(&target_buffer) {
1746                self.set_active_buffer(target_buffer);
1747
1748                // Move cursor to the saved position
1749                let state = self.active_state_mut();
1750                let cursor_id = state.cursors.primary_id();
1751                let old_position = state.cursors.primary().position;
1752                let old_anchor = state.cursors.primary().anchor;
1753                let old_sticky_column = state.cursors.primary().sticky_column;
1754                let event = Event::MoveCursor {
1755                    cursor_id,
1756                    old_position,
1757                    new_position: target_position,
1758                    old_anchor,
1759                    new_anchor: target_anchor,
1760                    old_sticky_column,
1761                    new_sticky_column: 0, // Reset sticky column for navigation
1762                };
1763                state.apply(&event);
1764            }
1765        }
1766
1767        // Clear the flag
1768        self.in_navigation = false;
1769    }
1770
1771    /// Navigate forward in position history
1772    pub fn navigate_forward(&mut self) {
1773        // Set flag to prevent recording this navigation movement
1774        self.in_navigation = true;
1775
1776        if let Some(entry) = self.position_history.forward() {
1777            let target_buffer = entry.buffer_id;
1778            let target_position = entry.position;
1779            let target_anchor = entry.anchor;
1780
1781            // Switch to the target buffer
1782            if self.buffers.contains_key(&target_buffer) {
1783                self.set_active_buffer(target_buffer);
1784
1785                // Move cursor to the saved position
1786                let state = self.active_state_mut();
1787                let cursor_id = state.cursors.primary_id();
1788                let old_position = state.cursors.primary().position;
1789                let old_anchor = state.cursors.primary().anchor;
1790                let old_sticky_column = state.cursors.primary().sticky_column;
1791                let event = Event::MoveCursor {
1792                    cursor_id,
1793                    old_position,
1794                    new_position: target_position,
1795                    old_anchor,
1796                    new_anchor: target_anchor,
1797                    old_sticky_column,
1798                    new_sticky_column: 0, // Reset sticky column for navigation
1799                };
1800                state.apply(&event);
1801            }
1802        }
1803
1804        // Clear the flag
1805        self.in_navigation = false;
1806    }
1807
1808    /// Get the current mouse hover state for testing
1809    /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
1810    pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
1811        self.mouse_state
1812            .lsp_hover_state
1813            .map(|(pos, _, x, y)| (pos, x, y))
1814    }
1815
1816    /// Check if a transient popup (hover/signature help) is currently visible
1817    pub fn has_transient_popup(&self) -> bool {
1818        self.active_state()
1819            .popups
1820            .top()
1821            .is_some_and(|p| p.transient)
1822    }
1823
1824    /// Force check the mouse hover timer (for testing)
1825    /// This bypasses the normal 500ms delay
1826    pub fn force_check_mouse_hover(&mut self) -> bool {
1827        // Temporarily mark the hover as ready by checking if state exists
1828        if let Some((byte_pos, _, screen_x, screen_y)) = self.mouse_state.lsp_hover_state {
1829            if !self.mouse_state.lsp_hover_request_sent {
1830                self.mouse_state.lsp_hover_request_sent = true;
1831                self.mouse_hover_screen_position = Some((screen_x, screen_y));
1832                if let Err(e) = self.request_hover_at_position(byte_pos) {
1833                    tracing::debug!("Failed to request hover: {}", e);
1834                    return false;
1835                }
1836                return true;
1837            }
1838        }
1839        false
1840    }
1841}