Skip to main content

fresh/app/
file_open_orchestrators.rs

1//! File-open orchestrators on `Editor`.
2//!
3//! The `open_file` family — open_file, open_file_no_focus,
4//! open_local_file, open_file_with_encoding, reload_with_encoding,
5//! open_file_large_encoding_confirmed — and supporting helpers
6//! restore_global_file_state and save_file_state_on_close.
7//!
8//! Opening a file in this editor coordinates: detecting the file type,
9//! choosing or creating a buffer, registering with the LSP, parsing
10//! grammar, restoring per-file UI state (cursor position, scroll), and
11//! deciding which split to focus. Each variant differs only in how it
12//! handles encoding errors, focus, and "no file at this path yet" cases.
13
14use std::path::Path;
15use std::sync::Arc;
16
17use anyhow::Result as AnyhowResult;
18use rust_i18n::t;
19
20use crate::model::event::{BufferId, LeafId};
21use crate::state::EditorState;
22
23use super::Editor;
24
25impl Editor {
26    /// Open a file and return its buffer ID
27    ///
28    /// If the file doesn't exist, creates an unsaved buffer with that filename.
29    /// Saving the buffer will create the file.
30    pub fn open_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
31        // Check whether the active buffer had a file path before loading.
32        // If it didn't, open_file_no_focus may replace the empty initial buffer
33        // in-place (same buffer ID, new content), and we need to notify plugins.
34        let active_had_path = self
35            .buffers
36            .get(&self.active_buffer())
37            .and_then(|s| s.buffer.file_path())
38            .is_some();
39
40        let buffer_id = self.open_file_no_focus(path)?;
41
42        // Check if this was an already-open buffer or a new one
43        // For already-open buffers, just switch to them
44        // For new buffers, record position history before switching
45        let is_new_buffer = self.active_buffer() != buffer_id;
46
47        if is_new_buffer && !self.suppress_position_history_once {
48            // Save current position before switching to new buffer
49            self.position_history.commit_pending_movement();
50
51            // Explicitly record current position before switching
52            let cursors = self.active_cursors();
53            let position = cursors.primary().position;
54            let anchor = cursors.primary().anchor;
55            self.position_history
56                .record_movement(self.active_buffer(), position, anchor);
57            self.position_history.commit_pending_movement();
58        }
59
60        self.set_active_buffer(buffer_id);
61
62        // If the initial empty buffer was replaced in-place with file content,
63        // set_active_buffer is a no-op (same buffer ID). Fire buffer_activated
64        // explicitly so plugins see the newly loaded file.
65        // Skip this when re-opening an already-active file (active_had_path),
66        // as nothing changed and the extra hook would cause spurious refreshes
67        // in plugins like the diagnostics panel.
68        if !is_new_buffer && !active_had_path {
69            #[cfg(feature = "plugins")]
70            self.update_plugin_state_snapshot();
71
72            self.plugin_manager.run_hook(
73                "buffer_activated",
74                crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
75            );
76        }
77
78        // Use display_name from metadata for relative path display
79        let display_name = self
80            .buffer_metadata
81            .get(&buffer_id)
82            .map(|m| m.display_name.clone())
83            .unwrap_or_else(|| path.display().to_string());
84
85        // Check if buffer is binary for status message
86        let is_binary = self
87            .buffers
88            .get(&buffer_id)
89            .map(|s| s.buffer.is_binary())
90            .unwrap_or(false);
91
92        // Show appropriate status message for binary vs regular files
93        if is_binary {
94            self.status_message = Some(t!("buffer.opened_binary", name = display_name).to_string());
95        } else {
96            self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
97        }
98
99        Ok(buffer_id)
100    }
101
102    /// Open a file without switching focus to it
103    ///
104    /// Creates a new buffer for the file (or returns existing buffer ID if already open)
105    /// but does not change the active buffer. Useful for opening files in background tabs.
106    ///
107    /// If the file doesn't exist, creates an unsaved buffer with that filename.
108    pub fn open_file_no_focus(&mut self, path: &Path) -> anyhow::Result<BufferId> {
109        // Fail fast if the remote connection is down — don't attempt I/O that
110        // would either timeout or return confusing errors.
111        if !self.filesystem.is_remote_connected() {
112            anyhow::bail!(
113                "Cannot open file: remote connection lost ({})",
114                self.filesystem
115                    .remote_connection_info()
116                    .unwrap_or("unknown host")
117            );
118        }
119
120        // Resolve relative paths against appropriate base directory
121        // For remote mode, use the remote home directory; for local, use working_dir
122        let base_dir = if self.filesystem.remote_connection_info().is_some() {
123            self.filesystem
124                .home_dir()
125                .unwrap_or_else(|_| self.working_dir.clone())
126        } else {
127            self.working_dir.clone()
128        };
129
130        let resolved_path = if path.is_relative() {
131            base_dir.join(path)
132        } else {
133            path.to_path_buf()
134        };
135
136        // Determine if we're opening a non-existent file (for creating new files)
137        // Use filesystem trait method to support remote files
138        let file_exists = self.filesystem.exists(&resolved_path);
139
140        // Save the user-visible (non-canonicalized) path for language detection.
141        // Glob patterns in language config should match the path as the user sees it,
142        // not the canonical path (e.g., on macOS /var -> /private/var symlinks).
143        let display_path = resolved_path.clone();
144
145        // Canonicalize the path to resolve symlinks and normalize path components
146        // This ensures consistent path representation throughout the editor
147        // For non-existent files, we need to canonicalize the parent directory and append the filename
148        let canonical_path = if file_exists {
149            self.filesystem
150                .canonicalize(&resolved_path)
151                .unwrap_or_else(|_| resolved_path.clone())
152        } else {
153            // For non-existent files, canonicalize parent dir and append filename
154            if let Some(parent) = resolved_path.parent() {
155                let canonical_parent = if parent.as_os_str().is_empty() {
156                    // No parent means just a filename, use base dir
157                    base_dir.clone()
158                } else {
159                    self.filesystem
160                        .canonicalize(parent)
161                        .unwrap_or_else(|_| parent.to_path_buf())
162                };
163                if let Some(filename) = resolved_path.file_name() {
164                    canonical_parent.join(filename)
165                } else {
166                    resolved_path
167                }
168            } else {
169                resolved_path
170            }
171        };
172        let path = canonical_path.as_path();
173
174        // Check if the path is a directory (after following symlinks via canonicalize)
175        // Directories cannot be opened as files in the editor
176        // Use filesystem trait method to support remote files
177        if self.filesystem.is_dir(path).unwrap_or(false) {
178            anyhow::bail!(t!("buffer.cannot_open_directory"));
179        }
180
181        // Check if file is already open - return existing buffer without switching
182        let already_open = self
183            .buffers
184            .iter()
185            .find(|(_, state)| state.buffer.file_path() == Some(path))
186            .map(|(id, _)| *id);
187
188        if let Some(id) = already_open {
189            return Ok(id);
190        }
191
192        // If the current buffer is empty and unmodified, replace it instead of creating a new one
193        // Note: Don't replace composite buffers (they appear empty but are special views)
194        let replace_current = {
195            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
196            !current_state.is_composite_buffer
197                && current_state.buffer.is_empty()
198                && !current_state.buffer.is_modified()
199                && current_state.buffer.file_path().is_none()
200        };
201
202        let buffer_id = if replace_current {
203            // Reuse the current empty buffer
204            self.active_buffer()
205        } else {
206            // Create new buffer for this file
207            let id = BufferId(self.next_buffer_id);
208            self.next_buffer_id += 1;
209            id
210        };
211
212        // Create the editor state - either load from file or create empty buffer
213        tracing::info!(
214            "[SYNTAX DEBUG] open_file_no_focus: path={:?}, extension={:?}, catalog={}",
215            path,
216            path.extension(),
217            self.grammar_registry.catalog().len(),
218        );
219        let mut state = if file_exists {
220            // Load from canonical path (for I/O and dedup), detect language from
221            // display path (for glob pattern matching against user-visible names).
222            let buffer = crate::model::buffer::Buffer::load_from_file(
223                &canonical_path,
224                self.config.editor.large_file_threshold_bytes as usize,
225                Arc::clone(&self.filesystem),
226            )?;
227            let detected =
228                crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
229                    &display_path,
230                    &self.grammar_registry,
231                    &self.config.languages,
232                    self.config.default_language.as_deref(),
233                );
234            EditorState::from_buffer_with_language(buffer, detected)
235        } else {
236            // File doesn't exist - create empty buffer with the file path set
237            EditorState::new_with_path(
238                self.config.editor.large_file_threshold_bytes as usize,
239                Arc::clone(&self.filesystem),
240                path.to_path_buf(),
241            )
242        };
243        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
244
245        // Check if the buffer contains binary content
246        let is_binary = state.buffer.is_binary();
247        if is_binary {
248            // Make binary buffers read-only
249            state.editing_disabled = true;
250            tracing::info!("Detected binary file: {}", path.display());
251        }
252
253        // Set whitespace visibility, use_tabs, and tab_size based on language config
254        // with fallback to global editor config for tab_size
255        // Use the buffer's stored language (already set by from_file_with_languages)
256        let mut whitespace =
257            crate::config::WhitespaceVisibility::from_editor_config(&self.config.editor);
258        state.buffer_settings.auto_close = self.config.editor.auto_close;
259        state.buffer_settings.auto_surround = self.config.editor.auto_surround;
260        if let Some(lang_config) = self.config.languages.get(&state.language) {
261            whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
262            state.buffer_settings.use_tabs =
263                lang_config.use_tabs.unwrap_or(self.config.editor.use_tabs);
264            // Use language-specific tab_size if set, otherwise fall back to global
265            state.buffer_settings.tab_size =
266                lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
267            // Auto close: language override (only if globally enabled)
268            if state.buffer_settings.auto_close {
269                if let Some(lang_auto_close) = lang_config.auto_close {
270                    state.buffer_settings.auto_close = lang_auto_close;
271                }
272            }
273            // Auto surround: language override (only if globally enabled)
274            if state.buffer_settings.auto_surround {
275                if let Some(lang_auto_surround) = lang_config.auto_surround {
276                    state.buffer_settings.auto_surround = lang_auto_surround;
277                }
278            }
279        } else {
280            state.buffer_settings.tab_size = self.config.editor.tab_size;
281            state.buffer_settings.use_tabs = self.config.editor.use_tabs;
282        }
283        state.buffer_settings.whitespace = whitespace;
284
285        // Apply line_numbers default from config
286        state
287            .margins
288            .configure_for_line_numbers(self.config.editor.line_numbers);
289
290        self.buffers.insert(buffer_id, state);
291        self.event_logs
292            .insert(buffer_id, crate::model::event::EventLog::new());
293
294        // Create metadata for this buffer
295        let mut metadata = super::types::BufferMetadata::with_file(
296            path.to_path_buf(),
297            &display_path,
298            &self.working_dir,
299        );
300
301        // Mark binary files in metadata and disable LSP
302        if is_binary {
303            metadata.binary = true;
304            metadata.read_only = true;
305            metadata.disable_lsp(t!("buffer.binary_file").to_string());
306        }
307
308        // Check if the file is read-only on disk (filesystem permissions)
309        if file_exists && !metadata.read_only && !self.filesystem.is_writable(path) {
310            metadata.read_only = true;
311        }
312
313        // Mark read-only files (library, binary, or filesystem-readonly) as editing-disabled
314        if metadata.read_only {
315            if let Some(state) = self.buffers.get_mut(&buffer_id) {
316                state.editing_disabled = true;
317            }
318        }
319
320        // Notify LSP about the newly opened file (skip for binary files)
321        if !is_binary {
322            self.notify_lsp_file_opened(path, buffer_id, &mut metadata);
323        }
324
325        // Store metadata for this buffer
326        self.buffer_metadata.insert(buffer_id, metadata);
327
328        // Add buffer to the preferred split's tabs (but don't switch to it)
329        // Uses preferred_split_for_file() to avoid opening in labeled splits (e.g., sidebars)
330        let target_split = self.preferred_split_for_file();
331        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
332        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
333        let page_view = self.resolve_page_view_for_buffer(buffer_id);
334        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
335            view_state.add_buffer(buffer_id);
336            // Initialize per-buffer view state for the new buffer with config defaults
337            let buf_state = view_state.ensure_buffer_state(buffer_id);
338            buf_state.apply_config_defaults(
339                self.config.editor.line_numbers,
340                self.config.editor.highlight_current_line,
341                line_wrap,
342                self.config.editor.wrap_indent,
343                wrap_column,
344                self.config.editor.rulers.clone(),
345            );
346            // Auto-activate page view if configured for this language
347            if let Some(page_width) = page_view {
348                buf_state.activate_page_view(page_width);
349            }
350        }
351
352        // Restore global file state (scroll/cursor position) if available
353        // This persists file positions across projects and editor instances
354        self.restore_global_file_state(buffer_id, path, target_split);
355
356        // Emit control event
357        self.emit_event(
358            crate::model::control_event::events::FILE_OPENED.name,
359            serde_json::json!({
360                "path": path.display().to_string(),
361                "buffer_id": buffer_id.0
362            }),
363        );
364
365        // Track file for auto-revert and conflict detection
366        self.watch_file(path);
367
368        // Fire AfterFileOpen hook for plugins
369        self.plugin_manager.run_hook(
370            "after_file_open",
371            crate::services::plugins::hooks::HookArgs::AfterFileOpen {
372                buffer_id,
373                path: path.to_path_buf(),
374            },
375        );
376
377        Ok(buffer_id)
378    }
379
380    /// Open a local file (always uses local filesystem, not remote)
381    ///
382    /// This is used for opening local files like log files when in remote mode.
383    /// Unlike `open_file`, this always uses the local filesystem even when
384    /// the editor is connected to a remote server.
385    pub fn open_local_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
386        // Resolve relative paths against working_dir
387        let resolved_path = if path.is_relative() {
388            self.working_dir.join(path)
389        } else {
390            path.to_path_buf()
391        };
392
393        // Save user-visible path for language detection before canonicalizing
394        let display_path = resolved_path.clone();
395
396        // Canonicalize the path
397        let canonical_path = resolved_path
398            .canonicalize()
399            .unwrap_or_else(|_| resolved_path.clone());
400        let path = canonical_path.as_path();
401
402        // Check if already open
403        let already_open = self
404            .buffers
405            .iter()
406            .find(|(_, state)| state.buffer.file_path() == Some(path))
407            .map(|(id, _)| *id);
408
409        if let Some(id) = already_open {
410            self.set_active_buffer(id);
411            return Ok(id);
412        }
413
414        // Create new buffer
415        let buffer_id = BufferId(self.next_buffer_id);
416        self.next_buffer_id += 1;
417
418        // Load from canonical path (for I/O and dedup), detect language from
419        // display path (for glob pattern matching against user-visible names).
420        let buffer = crate::model::buffer::Buffer::load_from_file(
421            &canonical_path,
422            self.config.editor.large_file_threshold_bytes as usize,
423            Arc::clone(&self.local_filesystem),
424        )?;
425        let detected =
426            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
427                &display_path,
428                &self.grammar_registry,
429                &self.config.languages,
430                self.config.default_language.as_deref(),
431            );
432        let state = EditorState::from_buffer_with_language(buffer, detected);
433
434        self.buffers.insert(buffer_id, state);
435        self.event_logs
436            .insert(buffer_id, crate::model::event::EventLog::new());
437
438        // Create metadata
439        let metadata = super::types::BufferMetadata::with_file(
440            path.to_path_buf(),
441            &display_path,
442            &self.working_dir,
443        );
444        self.buffer_metadata.insert(buffer_id, metadata);
445
446        // Add to preferred split's tabs (avoids labeled splits like sidebars)
447        let target_split = self.preferred_split_for_file();
448        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
449        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
450        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
451            view_state.add_buffer(buffer_id);
452            let buf_state = view_state.ensure_buffer_state(buffer_id);
453            buf_state.apply_config_defaults(
454                self.config.editor.line_numbers,
455                self.config.editor.highlight_current_line,
456                line_wrap,
457                self.config.editor.wrap_indent,
458                wrap_column,
459                self.config.editor.rulers.clone(),
460            );
461        }
462
463        self.set_active_buffer(buffer_id);
464
465        let display_name = path.display().to_string();
466        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
467
468        Ok(buffer_id)
469    }
470
471    /// Open a file with a specific encoding (no auto-detection).
472    ///
473    /// Used when the user disables auto-detection in the file browser
474    /// and selects a specific encoding to use.
475    pub fn open_file_with_encoding(
476        &mut self,
477        path: &Path,
478        encoding: crate::model::buffer::Encoding,
479    ) -> anyhow::Result<BufferId> {
480        // Use the same base directory logic as open_file
481        let base_dir = self.working_dir.clone();
482
483        let resolved_path = if path.is_relative() {
484            base_dir.join(path)
485        } else {
486            path.to_path_buf()
487        };
488
489        // Save user-visible path for language detection before canonicalizing
490        let display_path = resolved_path.clone();
491
492        // Canonicalize the path
493        let canonical_path = self
494            .filesystem
495            .canonicalize(&resolved_path)
496            .unwrap_or_else(|_| resolved_path.clone());
497        let path = canonical_path.as_path();
498
499        // Check if already open
500        let already_open = self
501            .buffers
502            .iter()
503            .find(|(_, state)| state.buffer.file_path() == Some(path))
504            .map(|(id, _)| *id);
505
506        if let Some(id) = already_open {
507            // File is already open - update its encoding and reload
508            if let Some(state) = self.buffers.get_mut(&id) {
509                state.buffer.set_encoding(encoding);
510            }
511            self.set_active_buffer(id);
512            return Ok(id);
513        }
514
515        // Create new buffer with specified encoding
516        let buffer_id = BufferId(self.next_buffer_id);
517        self.next_buffer_id += 1;
518
519        // Load buffer with the specified encoding (use canonical path for I/O)
520        let buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
521            path,
522            encoding,
523            Arc::clone(&self.filesystem),
524            crate::model::buffer::BufferConfig {
525                estimated_line_length: self.config.editor.estimated_line_length,
526            },
527        )?;
528        // Create editor state with the buffer
529        // Use display_path for language detection (glob patterns match user-visible paths)
530        let detected =
531            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
532                &display_path,
533                &self.grammar_registry,
534                &self.config.languages,
535                self.config.default_language.as_deref(),
536            );
537
538        let mut state = EditorState::from_buffer_with_language(buffer, detected);
539
540        state
541            .margins
542            .configure_for_line_numbers(self.config.editor.line_numbers);
543
544        self.buffers.insert(buffer_id, state);
545        self.event_logs
546            .insert(buffer_id, crate::model::event::EventLog::new());
547
548        let metadata = super::types::BufferMetadata::with_file(
549            path.to_path_buf(),
550            &display_path,
551            &self.working_dir,
552        );
553        self.buffer_metadata.insert(buffer_id, metadata);
554
555        // Add to preferred split's tabs (avoids labeled splits like sidebars)
556        let target_split = self.preferred_split_for_file();
557        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
558        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
559        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
560            view_state.add_buffer(buffer_id);
561            let buf_state = view_state.ensure_buffer_state(buffer_id);
562            buf_state.apply_config_defaults(
563                self.config.editor.line_numbers,
564                self.config.editor.highlight_current_line,
565                line_wrap,
566                self.config.editor.wrap_indent,
567                wrap_column,
568                self.config.editor.rulers.clone(),
569            );
570        }
571
572        self.set_active_buffer(buffer_id);
573
574        Ok(buffer_id)
575    }
576
577    /// Reload the current file with a specific encoding.
578    ///
579    /// Requires the buffer to have no unsaved modifications.
580    pub fn reload_with_encoding(
581        &mut self,
582        encoding: crate::model::buffer::Encoding,
583    ) -> anyhow::Result<()> {
584        let buffer_id = self.active_buffer();
585
586        // Get the file path
587        let path = self
588            .buffers
589            .get(&buffer_id)
590            .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()))
591            .ok_or_else(|| anyhow::anyhow!("Buffer has no file path"))?;
592
593        // Check for unsaved modifications
594        if let Some(state) = self.buffers.get(&buffer_id) {
595            if state.buffer.is_modified() {
596                anyhow::bail!("Cannot reload: buffer has unsaved modifications");
597            }
598        }
599
600        // Reload the buffer with the new encoding
601        let new_buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
602            &path,
603            encoding,
604            Arc::clone(&self.filesystem),
605            crate::model::buffer::BufferConfig {
606                estimated_line_length: self.config.editor.estimated_line_length,
607            },
608        )?;
609
610        // Update the buffer in the editor state
611        if let Some(state) = self.buffers.get_mut(&buffer_id) {
612            state.buffer = new_buffer;
613            // Invalidate highlighting
614            state.highlighter.invalidate_all();
615        }
616
617        // Reset cursor to start in the split view state
618        let split_id = self.split_manager.active_split();
619        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
620            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
621                buf_state.cursors = crate::model::cursor::Cursors::new();
622            }
623        }
624
625        Ok(())
626    }
627
628    /// Open a large file with confirmed full loading for non-resynchronizable encoding.
629    ///
630    /// Called after user confirms they want to load a large file with an encoding like
631    /// GB18030, GBK, Shift-JIS, or EUC-KR that requires loading the entire file into memory.
632    pub fn open_file_large_encoding_confirmed(&mut self, path: &Path) -> anyhow::Result<BufferId> {
633        // Use the same base directory logic as open_file
634        let base_dir = self.working_dir.clone();
635
636        let resolved_path = if path.is_relative() {
637            base_dir.join(path)
638        } else {
639            path.to_path_buf()
640        };
641
642        // Save user-visible path for language detection before canonicalizing
643        let display_path = resolved_path.clone();
644
645        // Canonicalize the path
646        let canonical_path = self
647            .filesystem
648            .canonicalize(&resolved_path)
649            .unwrap_or_else(|_| resolved_path.clone());
650        let path = canonical_path.as_path();
651
652        // Check if already open
653        let already_open = self
654            .buffers
655            .iter()
656            .find(|(_, state)| state.buffer.file_path() == Some(path))
657            .map(|(id, _)| *id);
658
659        if let Some(id) = already_open {
660            self.set_active_buffer(id);
661            return Ok(id);
662        }
663
664        // Create new buffer with forced full loading
665        let buffer_id = BufferId(self.next_buffer_id);
666        self.next_buffer_id += 1;
667
668        // Load buffer with forced full loading (bypasses the large file encoding check)
669        let buffer = crate::model::buffer::Buffer::load_large_file_confirmed(
670            path,
671            Arc::clone(&self.filesystem),
672        )?;
673        // Create editor state with the buffer
674        // Use display_path for language detection (glob patterns match user-visible paths)
675        let detected =
676            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
677                &display_path,
678                &self.grammar_registry,
679                &self.config.languages,
680                self.config.default_language.as_deref(),
681            );
682
683        let mut state = EditorState::from_buffer_with_language(buffer, detected);
684
685        state
686            .margins
687            .configure_for_line_numbers(self.config.editor.line_numbers);
688
689        self.buffers.insert(buffer_id, state);
690        self.event_logs
691            .insert(buffer_id, crate::model::event::EventLog::new());
692
693        let metadata = super::types::BufferMetadata::with_file(
694            path.to_path_buf(),
695            &display_path,
696            &self.working_dir,
697        );
698        self.buffer_metadata.insert(buffer_id, metadata);
699
700        // Add to preferred split's tabs (avoids labeled splits like sidebars)
701        let target_split = self.preferred_split_for_file();
702        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
703        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
704        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
705            view_state.add_buffer(buffer_id);
706            let buf_state = view_state.ensure_buffer_state(buffer_id);
707            buf_state.apply_config_defaults(
708                self.config.editor.line_numbers,
709                self.config.editor.highlight_current_line,
710                line_wrap,
711                self.config.editor.wrap_indent,
712                wrap_column,
713                self.config.editor.rulers.clone(),
714            );
715        }
716
717        self.set_active_buffer(buffer_id);
718
719        // Use display_name from metadata for relative path display
720        let display_name = self
721            .buffer_metadata
722            .get(&buffer_id)
723            .map(|m| m.display_name.clone())
724            .unwrap_or_else(|| path.display().to_string());
725
726        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
727
728        Ok(buffer_id)
729    }
730
731    /// Restore global file state (cursor and scroll position) for a newly opened file
732    ///
733    /// This looks up the file's saved state from the global file states store
734    /// and applies it to both the EditorState (cursor) and SplitViewState (viewport).
735    fn restore_global_file_state(&mut self, buffer_id: BufferId, path: &Path, split_id: LeafId) {
736        use crate::workspace::PersistedFileWorkspace;
737
738        // Load the per-file state for this path (lazy load from disk)
739        let file_state = match PersistedFileWorkspace::load(path) {
740            Some(state) => state,
741            None => return, // No saved state for this file
742        };
743
744        // Get the buffer to validate positions
745        let max_pos = match self.buffers.get(&buffer_id) {
746            Some(buffer) => buffer.buffer.len(),
747            None => return,
748        };
749
750        // Apply cursor position and viewport (scroll) state to SplitViewState
751        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
752            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
753                let cursor_pos = file_state.cursor.position.min(max_pos);
754                buf_state.cursors.primary_mut().position = cursor_pos;
755                buf_state.cursors.primary_mut().anchor =
756                    file_state.cursor.anchor.map(|a| a.min(max_pos));
757            }
758            view_state.viewport.top_byte = file_state.scroll.top_byte;
759            view_state.viewport.left_column = file_state.scroll.left_column;
760        }
761    }
762
763    /// Save file state when a buffer is closed (for per-file session persistence)
764    pub(super) fn save_file_state_on_close(&self, buffer_id: BufferId) {
765        use crate::workspace::{
766            PersistedFileWorkspace, SerializedCursor, SerializedFileState, SerializedScroll,
767        };
768
769        // Get the file path for this buffer
770        let abs_path = match self.buffer_metadata.get(&buffer_id) {
771            Some(metadata) => match metadata.file_path() {
772                Some(path) => path.to_path_buf(),
773                None => return, // Not a file buffer
774            },
775            None => return,
776        };
777
778        // Find a split that has this buffer open to get the view state
779        let view_state = self
780            .split_view_states
781            .values()
782            .find(|vs| vs.has_buffer(buffer_id));
783
784        let view_state = match view_state {
785            Some(vs) => vs,
786            None => return, // No split has this buffer
787        };
788
789        // Get the per-buffer view state (not necessarily the active buffer in this split)
790        let buf_state = match view_state.keyed_states.get(&buffer_id) {
791            Some(bs) => bs,
792            None => return,
793        };
794
795        // Capture the current state
796        let primary_cursor = buf_state.cursors.primary();
797        let file_state = SerializedFileState {
798            cursor: SerializedCursor {
799                position: primary_cursor.position,
800                anchor: primary_cursor.anchor,
801                sticky_column: primary_cursor.sticky_column,
802            },
803            additional_cursors: buf_state
804                .cursors
805                .iter()
806                .skip(1)
807                .map(|(_, cursor)| SerializedCursor {
808                    position: cursor.position,
809                    anchor: cursor.anchor,
810                    sticky_column: cursor.sticky_column,
811                })
812                .collect(),
813            scroll: SerializedScroll {
814                top_byte: buf_state.viewport.top_byte,
815                top_view_line_offset: buf_state.viewport.top_view_line_offset,
816                left_column: buf_state.viewport.left_column,
817            },
818            view_mode: Default::default(),
819            compose_width: None,
820            plugin_state: std::collections::HashMap::new(),
821            folds: Vec::new(),
822        };
823
824        // Save to disk
825        PersistedFileWorkspace::save(&abs_path, file_state);
826        tracing::debug!("Saved file state on close for {:?}", abs_path);
827    }
828}