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