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.authority.filesystem.is_remote_connected() {
111            anyhow::bail!(
112                "Cannot open file: remote connection lost ({})",
113                self.authority
114                    .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.authority.filesystem.remote_connection_info().is_some() {
123            self.authority
124                .filesystem
125                .home_dir()
126                .unwrap_or_else(|_| self.working_dir.clone())
127        } else {
128            self.working_dir.clone()
129        };
130
131        let resolved_path = if path.is_relative() {
132            base_dir.join(path)
133        } else {
134            path.to_path_buf()
135        };
136
137        // Determine if we're opening a non-existent file (for creating new files)
138        // Use filesystem trait method to support remote files
139        let file_exists = self.authority.filesystem.exists(&resolved_path);
140
141        // Save the user-visible (non-canonicalized) path for language detection.
142        // Glob patterns in language config should match the path as the user sees it,
143        // not the canonical path (e.g., on macOS /var -> /private/var symlinks).
144        let display_path = resolved_path.clone();
145
146        // Canonicalize the path to resolve symlinks and normalize path components
147        // This ensures consistent path representation throughout the editor
148        // For non-existent files, we need to canonicalize the parent directory and append the filename
149        let canonical_path = if file_exists {
150            self.authority
151                .filesystem
152                .canonicalize(&resolved_path)
153                .unwrap_or_else(|_| resolved_path.clone())
154        } else {
155            // For non-existent files, canonicalize parent dir and append filename
156            if let Some(parent) = resolved_path.parent() {
157                let canonical_parent = if parent.as_os_str().is_empty() {
158                    // No parent means just a filename, use base dir
159                    base_dir.clone()
160                } else {
161                    self.authority
162                        .filesystem
163                        .canonicalize(parent)
164                        .unwrap_or_else(|_| parent.to_path_buf())
165                };
166                if let Some(filename) = resolved_path.file_name() {
167                    canonical_parent.join(filename)
168                } else {
169                    resolved_path
170                }
171            } else {
172                resolved_path
173            }
174        };
175        let path = canonical_path.as_path();
176
177        // Check if the path is a directory (after following symlinks via canonicalize)
178        // Directories cannot be opened as files in the editor
179        // Use filesystem trait method to support remote files
180        if self.authority.filesystem.is_dir(path).unwrap_or(false) {
181            anyhow::bail!(t!("buffer.cannot_open_directory"));
182        }
183
184        // Check if file is already open - return existing buffer without switching
185        let already_open = self
186            .buffers
187            .iter()
188            .find(|(_, state)| state.buffer.file_path() == Some(path))
189            .map(|(id, _)| *id);
190
191        if let Some(id) = already_open {
192            return Ok(id);
193        }
194
195        // If the current buffer is empty and unmodified, replace it instead of creating a new one
196        // Note: Don't replace composite buffers (they appear empty but are special views)
197        let replace_current = {
198            let current_state = self.buffers.get(&self.active_buffer()).unwrap();
199            !current_state.is_composite_buffer
200                && current_state.buffer.is_empty()
201                && !current_state.buffer.is_modified()
202                && current_state.buffer.file_path().is_none()
203        };
204
205        let buffer_id = if replace_current {
206            // Reuse the current empty buffer
207            self.active_buffer()
208        } else {
209            // Create new buffer for this file
210            let id = BufferId(self.next_buffer_id);
211            self.next_buffer_id += 1;
212            id
213        };
214
215        // Create the editor state - either load from file or create empty buffer
216        tracing::info!(
217            "[SYNTAX DEBUG] open_file_no_focus: path={:?}, extension={:?}, catalog={}",
218            path,
219            path.extension(),
220            self.grammar_registry.catalog().len(),
221        );
222        let mut state = if file_exists {
223            // Load from canonical path (for I/O and dedup), detect language from
224            // display path (for glob pattern matching against user-visible names).
225            let buffer = crate::model::buffer::Buffer::load_from_file(
226                &canonical_path,
227                self.config.editor.large_file_threshold_bytes as usize,
228                Arc::clone(&self.authority.filesystem),
229            )?;
230            let first_line = buffer.first_line_lossy();
231            let detected =
232                crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
233                    &display_path,
234                    first_line.as_deref(),
235                    &self.grammar_registry,
236                    &self.config.languages,
237                    self.config.default_language.as_deref(),
238                );
239            EditorState::from_buffer_with_language(buffer, detected)
240        } else {
241            // File doesn't exist - create empty buffer with the file path set
242            EditorState::new_with_path(
243                self.config.editor.large_file_threshold_bytes as usize,
244                Arc::clone(&self.authority.filesystem),
245                path.to_path_buf(),
246            )
247        };
248        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
249
250        // Check if the buffer contains binary content
251        let is_binary = state.buffer.is_binary();
252        if is_binary {
253            // Make binary buffers read-only
254            state.editing_disabled = true;
255            tracing::info!("Detected binary file: {}", path.display());
256        }
257
258        // Set whitespace visibility, use_tabs, and tab_size based on language config
259        // with fallback to global editor config for tab_size
260        // Use the buffer's stored language (already set by from_file_with_languages)
261        let mut whitespace =
262            crate::config::WhitespaceVisibility::from_editor_config(&self.config.editor);
263        state.buffer_settings.auto_close = self.config.editor.auto_close;
264        state.buffer_settings.auto_surround = self.config.editor.auto_surround;
265        if let Some(lang_config) = self.config.languages.get(&state.language) {
266            whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
267            state.buffer_settings.use_tabs =
268                lang_config.use_tabs.unwrap_or(self.config.editor.use_tabs);
269            // Use language-specific tab_size if set, otherwise fall back to global
270            state.buffer_settings.tab_size =
271                lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
272            // Auto close: language override (only if globally enabled)
273            if state.buffer_settings.auto_close {
274                if let Some(lang_auto_close) = lang_config.auto_close {
275                    state.buffer_settings.auto_close = lang_auto_close;
276                }
277            }
278            // Auto surround: language override (only if globally enabled)
279            if state.buffer_settings.auto_surround {
280                if let Some(lang_auto_surround) = lang_config.auto_surround {
281                    state.buffer_settings.auto_surround = lang_auto_surround;
282                }
283            }
284        } else {
285            state.buffer_settings.tab_size = self.config.editor.tab_size;
286            state.buffer_settings.use_tabs = self.config.editor.use_tabs;
287        }
288        state.buffer_settings.whitespace = whitespace;
289
290        // Apply line_numbers default from config
291        state
292            .margins
293            .configure_for_line_numbers(self.config.editor.line_numbers);
294
295        self.buffers.insert(buffer_id, state);
296        self.event_logs
297            .insert(buffer_id, crate::model::event::EventLog::new());
298
299        // Create metadata for this buffer
300        let mut metadata = super::types::BufferMetadata::with_file(
301            path.to_path_buf(),
302            &display_path,
303            &self.working_dir,
304            self.authority.path_translation.as_ref(),
305        );
306
307        // Mark binary files in metadata and disable LSP
308        if is_binary {
309            metadata.binary = true;
310            metadata.read_only = true;
311            metadata.disable_lsp(t!("buffer.binary_file").to_string());
312        }
313
314        // Check if the file is read-only on disk (filesystem permissions)
315        if file_exists && !metadata.read_only && !self.authority.filesystem.is_writable(path) {
316            metadata.read_only = true;
317        }
318
319        // Mark read-only files (library, binary, or filesystem-readonly) as editing-disabled
320        if metadata.read_only {
321            if let Some(state) = self.buffers.get_mut(&buffer_id) {
322                state.editing_disabled = true;
323            }
324        }
325
326        // Notify LSP about the newly opened file (skip for binary files)
327        if !is_binary {
328            self.notify_lsp_file_opened(path, buffer_id, &mut metadata);
329        }
330
331        // Store metadata for this buffer
332        self.buffer_metadata.insert(buffer_id, metadata);
333
334        // Add buffer to the preferred split's tabs (but don't switch to it)
335        // Uses preferred_split_for_file() to avoid opening in labeled splits (e.g., sidebars)
336        let target_split = self.preferred_split_for_file();
337        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
338        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
339        let page_view = self.resolve_page_view_for_buffer(buffer_id);
340        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
341            view_state.add_buffer(buffer_id);
342            // Initialize per-buffer view state for the new buffer with config defaults
343            let buf_state = view_state.ensure_buffer_state(buffer_id);
344            buf_state.apply_config_defaults(
345                self.config.editor.line_numbers,
346                self.config.editor.highlight_current_line,
347                line_wrap,
348                self.config.editor.wrap_indent,
349                wrap_column,
350                self.config.editor.rulers.clone(),
351            );
352            // Auto-activate page view if configured for this language
353            if let Some(page_width) = page_view {
354                buf_state.activate_page_view(page_width);
355            }
356        }
357
358        // Restore global file state (scroll/cursor position) if available
359        // This persists file positions across projects and editor instances
360        self.restore_global_file_state(buffer_id, path, target_split);
361
362        // Emit control event
363        self.emit_event(
364            crate::model::control_event::events::FILE_OPENED.name,
365            serde_json::json!({
366                "path": path.display().to_string(),
367                "buffer_id": buffer_id.0
368            }),
369        );
370
371        // Track file for auto-revert and conflict detection
372        self.watch_file(path);
373
374        // Fire AfterFileOpen hook for plugins
375        self.plugin_manager.run_hook(
376            "after_file_open",
377            crate::services::plugins::hooks::HookArgs::AfterFileOpen {
378                buffer_id,
379                path: path.to_path_buf(),
380            },
381        );
382
383        Ok(buffer_id)
384    }
385
386    /// Open a local file (always uses local filesystem, not remote)
387    ///
388    /// This is used for opening local files like log files when in remote mode.
389    /// Unlike `open_file`, this always uses the local filesystem even when
390    /// the editor is connected to a remote server.
391    pub fn open_local_file(&mut self, path: &Path) -> anyhow::Result<BufferId> {
392        // Resolve relative paths against working_dir
393        let resolved_path = if path.is_relative() {
394            self.working_dir.join(path)
395        } else {
396            path.to_path_buf()
397        };
398
399        // Save user-visible path for language detection before canonicalizing
400        let display_path = resolved_path.clone();
401
402        // Canonicalize the path
403        let canonical_path = resolved_path
404            .canonicalize()
405            .unwrap_or_else(|_| resolved_path.clone());
406        let path = canonical_path.as_path();
407
408        // Check if already open
409        let already_open = self
410            .buffers
411            .iter()
412            .find(|(_, state)| state.buffer.file_path() == Some(path))
413            .map(|(id, _)| *id);
414
415        if let Some(id) = already_open {
416            self.set_active_buffer(id);
417            return Ok(id);
418        }
419
420        // Create new buffer
421        let buffer_id = BufferId(self.next_buffer_id);
422        self.next_buffer_id += 1;
423
424        // Load from canonical path (for I/O and dedup), detect language from
425        // display path (for glob pattern matching against user-visible names).
426        let buffer = crate::model::buffer::Buffer::load_from_file(
427            &canonical_path,
428            self.config.editor.large_file_threshold_bytes as usize,
429            Arc::clone(&self.local_filesystem),
430        )?;
431        let first_line = buffer.first_line_lossy();
432        let detected =
433            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
434                &display_path,
435                first_line.as_deref(),
436                &self.grammar_registry,
437                &self.config.languages,
438                self.config.default_language.as_deref(),
439            );
440        let state = EditorState::from_buffer_with_language(buffer, detected);
441
442        self.buffers.insert(buffer_id, state);
443        self.event_logs
444            .insert(buffer_id, crate::model::event::EventLog::new());
445
446        // Create metadata
447        let metadata = super::types::BufferMetadata::with_file(
448            path.to_path_buf(),
449            &display_path,
450            &self.working_dir,
451            self.authority.path_translation.as_ref(),
452        );
453        self.buffer_metadata.insert(buffer_id, metadata);
454
455        // Add to preferred split's tabs (avoids labeled splits like sidebars)
456        let target_split = self.preferred_split_for_file();
457        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
458        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
459        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
460            view_state.add_buffer(buffer_id);
461            let buf_state = view_state.ensure_buffer_state(buffer_id);
462            buf_state.apply_config_defaults(
463                self.config.editor.line_numbers,
464                self.config.editor.highlight_current_line,
465                line_wrap,
466                self.config.editor.wrap_indent,
467                wrap_column,
468                self.config.editor.rulers.clone(),
469            );
470        }
471
472        self.set_active_buffer(buffer_id);
473
474        let display_name = path.display().to_string();
475        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
476
477        Ok(buffer_id)
478    }
479
480    /// Open a file with a specific encoding (no auto-detection).
481    ///
482    /// Used when the user disables auto-detection in the file browser
483    /// and selects a specific encoding to use.
484    pub fn open_file_with_encoding(
485        &mut self,
486        path: &Path,
487        encoding: crate::model::buffer::Encoding,
488    ) -> anyhow::Result<BufferId> {
489        // Use the same base directory logic as open_file
490        let base_dir = self.working_dir.clone();
491
492        let resolved_path = if path.is_relative() {
493            base_dir.join(path)
494        } else {
495            path.to_path_buf()
496        };
497
498        // Save user-visible path for language detection before canonicalizing
499        let display_path = resolved_path.clone();
500
501        // Canonicalize the path
502        let canonical_path = self
503            .authority
504            .filesystem
505            .canonicalize(&resolved_path)
506            .unwrap_or_else(|_| resolved_path.clone());
507        let path = canonical_path.as_path();
508
509        // Check if already open
510        let already_open = self
511            .buffers
512            .iter()
513            .find(|(_, state)| state.buffer.file_path() == Some(path))
514            .map(|(id, _)| *id);
515
516        if let Some(id) = already_open {
517            // File is already open - update its encoding and reload
518            if let Some(state) = self.buffers.get_mut(&id) {
519                state.buffer.set_encoding(encoding);
520            }
521            self.set_active_buffer(id);
522            return Ok(id);
523        }
524
525        // Create new buffer with specified encoding
526        let buffer_id = BufferId(self.next_buffer_id);
527        self.next_buffer_id += 1;
528
529        // Load buffer with the specified encoding (use canonical path for I/O)
530        let buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
531            path,
532            encoding,
533            Arc::clone(&self.authority.filesystem),
534            crate::model::buffer::BufferConfig {
535                estimated_line_length: self.config.editor.estimated_line_length,
536            },
537        )?;
538        let first_line = buffer.first_line_lossy();
539        // Create editor state with the buffer
540        // Use display_path for language detection (glob patterns match user-visible paths)
541        let detected =
542            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
543                &display_path,
544                first_line.as_deref(),
545                &self.grammar_registry,
546                &self.config.languages,
547                self.config.default_language.as_deref(),
548            );
549
550        let mut state = EditorState::from_buffer_with_language(buffer, detected);
551
552        state
553            .margins
554            .configure_for_line_numbers(self.config.editor.line_numbers);
555
556        self.buffers.insert(buffer_id, state);
557        self.event_logs
558            .insert(buffer_id, crate::model::event::EventLog::new());
559
560        let metadata = super::types::BufferMetadata::with_file(
561            path.to_path_buf(),
562            &display_path,
563            &self.working_dir,
564            self.authority.path_translation.as_ref(),
565        );
566        self.buffer_metadata.insert(buffer_id, metadata);
567
568        // Add to preferred split's tabs (avoids labeled splits like sidebars)
569        let target_split = self.preferred_split_for_file();
570        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
571        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
572        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
573            view_state.add_buffer(buffer_id);
574            let buf_state = view_state.ensure_buffer_state(buffer_id);
575            buf_state.apply_config_defaults(
576                self.config.editor.line_numbers,
577                self.config.editor.highlight_current_line,
578                line_wrap,
579                self.config.editor.wrap_indent,
580                wrap_column,
581                self.config.editor.rulers.clone(),
582            );
583        }
584
585        self.set_active_buffer(buffer_id);
586
587        Ok(buffer_id)
588    }
589
590    /// Reload the current file with a specific encoding.
591    ///
592    /// Requires the buffer to have no unsaved modifications.
593    pub fn reload_with_encoding(
594        &mut self,
595        encoding: crate::model::buffer::Encoding,
596    ) -> anyhow::Result<()> {
597        let buffer_id = self.active_buffer();
598
599        // Get the file path
600        let path = self
601            .buffers
602            .get(&buffer_id)
603            .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()))
604            .ok_or_else(|| anyhow::anyhow!("Buffer has no file path"))?;
605
606        // Check for unsaved modifications
607        if let Some(state) = self.buffers.get(&buffer_id) {
608            if state.buffer.is_modified() {
609                anyhow::bail!("Cannot reload: buffer has unsaved modifications");
610            }
611        }
612
613        // Reload the buffer with the new encoding
614        let new_buffer = crate::model::buffer::Buffer::load_from_file_with_encoding(
615            &path,
616            encoding,
617            Arc::clone(&self.authority.filesystem),
618            crate::model::buffer::BufferConfig {
619                estimated_line_length: self.config.editor.estimated_line_length,
620            },
621        )?;
622
623        // Update the buffer in the editor state
624        if let Some(state) = self.buffers.get_mut(&buffer_id) {
625            state.buffer = new_buffer;
626            // Invalidate highlighting
627            state.highlighter.invalidate_all();
628        }
629
630        // Reset cursor to start in the split view state
631        let split_id = self.split_manager.active_split();
632        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
633            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
634                buf_state.cursors = crate::model::cursor::Cursors::new();
635            }
636        }
637
638        Ok(())
639    }
640
641    /// Open a large file with confirmed full loading for non-resynchronizable encoding.
642    ///
643    /// Called after user confirms they want to load a large file with an encoding like
644    /// GB18030, GBK, Shift-JIS, or EUC-KR that requires loading the entire file into memory.
645    pub fn open_file_large_encoding_confirmed(&mut self, path: &Path) -> anyhow::Result<BufferId> {
646        // Use the same base directory logic as open_file
647        let base_dir = self.working_dir.clone();
648
649        let resolved_path = if path.is_relative() {
650            base_dir.join(path)
651        } else {
652            path.to_path_buf()
653        };
654
655        // Save user-visible path for language detection before canonicalizing
656        let display_path = resolved_path.clone();
657
658        // Canonicalize the path
659        let canonical_path = self
660            .authority
661            .filesystem
662            .canonicalize(&resolved_path)
663            .unwrap_or_else(|_| resolved_path.clone());
664        let path = canonical_path.as_path();
665
666        // Check if already open
667        let already_open = self
668            .buffers
669            .iter()
670            .find(|(_, state)| state.buffer.file_path() == Some(path))
671            .map(|(id, _)| *id);
672
673        if let Some(id) = already_open {
674            self.set_active_buffer(id);
675            return Ok(id);
676        }
677
678        // Create new buffer with forced full loading
679        let buffer_id = BufferId(self.next_buffer_id);
680        self.next_buffer_id += 1;
681
682        // Load buffer with forced full loading (bypasses the large file encoding check)
683        let buffer = crate::model::buffer::Buffer::load_large_file_confirmed(
684            path,
685            Arc::clone(&self.authority.filesystem),
686        )?;
687        let first_line = buffer.first_line_lossy();
688        // Create editor state with the buffer
689        // Use display_path for language detection (glob patterns match user-visible paths)
690        let detected =
691            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
692                &display_path,
693                first_line.as_deref(),
694                &self.grammar_registry,
695                &self.config.languages,
696                self.config.default_language.as_deref(),
697            );
698
699        let mut state = EditorState::from_buffer_with_language(buffer, detected);
700
701        state
702            .margins
703            .configure_for_line_numbers(self.config.editor.line_numbers);
704
705        self.buffers.insert(buffer_id, state);
706        self.event_logs
707            .insert(buffer_id, crate::model::event::EventLog::new());
708
709        let metadata = super::types::BufferMetadata::with_file(
710            path.to_path_buf(),
711            &display_path,
712            &self.working_dir,
713            self.authority.path_translation.as_ref(),
714        );
715        self.buffer_metadata.insert(buffer_id, metadata);
716
717        // Add to preferred split's tabs (avoids labeled splits like sidebars)
718        let target_split = self.preferred_split_for_file();
719        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
720        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
721        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
722            view_state.add_buffer(buffer_id);
723            let buf_state = view_state.ensure_buffer_state(buffer_id);
724            buf_state.apply_config_defaults(
725                self.config.editor.line_numbers,
726                self.config.editor.highlight_current_line,
727                line_wrap,
728                self.config.editor.wrap_indent,
729                wrap_column,
730                self.config.editor.rulers.clone(),
731            );
732        }
733
734        self.set_active_buffer(buffer_id);
735
736        // Use display_name from metadata for relative path display
737        let display_name = self
738            .buffer_metadata
739            .get(&buffer_id)
740            .map(|m| m.display_name.clone())
741            .unwrap_or_else(|| path.display().to_string());
742
743        self.status_message = Some(t!("buffer.opened", name = display_name).to_string());
744
745        Ok(buffer_id)
746    }
747
748    /// Restore global file state (cursor and scroll position) for a newly opened file
749    ///
750    /// This looks up the file's saved state from the global file states store
751    /// and applies it to both the EditorState (cursor) and SplitViewState (viewport).
752    fn restore_global_file_state(&mut self, buffer_id: BufferId, path: &Path, split_id: LeafId) {
753        use crate::workspace::PersistedFileWorkspace;
754
755        // Load the per-file state for this path (lazy load from disk)
756        let file_state = match PersistedFileWorkspace::load(path) {
757            Some(state) => state,
758            None => return, // No saved state for this file
759        };
760
761        // Get the buffer to validate positions
762        let max_pos = match self.buffers.get(&buffer_id) {
763            Some(buffer) => buffer.buffer.len(),
764            None => return,
765        };
766
767        // Apply cursor position and viewport (scroll) state to SplitViewState.
768        // Field-disjoint borrows: `split_view_states` and `buffers` are
769        // separate fields, so we can hold mut borrows on both at once.
770        let view_state_opt = self.split_view_states.get_mut(&split_id);
771        let buffer_state_opt = self.buffers.get_mut(&buffer_id);
772        if let (Some(view_state), Some(buffer_state)) = (view_state_opt, buffer_state_opt) {
773            if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
774                let cursor_pos = file_state.cursor.position.min(max_pos);
775                buf_state.cursors.primary_mut().position = cursor_pos;
776                buf_state.cursors.primary_mut().anchor =
777                    file_state.cursor.anchor.map(|a| a.min(max_pos));
778            }
779            view_state.viewport.top_byte = file_state.scroll.top_byte;
780            view_state.viewport.left_column = file_state.scroll.left_column;
781            // Saved cursor and saved viewport are written from independent
782            // fields and may be out of sync (e.g. cursor moved off-screen
783            // before save). Reconcile so the restored view always shows the
784            // cursor — without this, arrow keys in wrap mode can't bring the
785            // viewport back because of the `top_view_line_offset > 0` early
786            // return in `viewport.rs::ensure_visible` (#1689 follow-up).
787            super::navigation::reconcile_restored_buffer_view(view_state, &mut buffer_state.buffer);
788        }
789    }
790
791    /// Save file state when a buffer is closed (for per-file session persistence)
792    pub(super) fn save_file_state_on_close(&self, buffer_id: BufferId) {
793        use crate::workspace::{
794            PersistedFileWorkspace, SerializedCursor, SerializedFileState, SerializedScroll,
795        };
796
797        // Get the file path for this buffer
798        let abs_path = match self.buffer_metadata.get(&buffer_id) {
799            Some(metadata) => match metadata.file_path() {
800                Some(path) => path.to_path_buf(),
801                None => return, // Not a file buffer
802            },
803            None => return,
804        };
805
806        // Find a split that has this buffer open to get the view state
807        let view_state = self
808            .split_view_states
809            .values()
810            .find(|vs| vs.has_buffer(buffer_id));
811
812        let view_state = match view_state {
813            Some(vs) => vs,
814            None => return, // No split has this buffer
815        };
816
817        // Get the per-buffer view state (not necessarily the active buffer in this split)
818        let buf_state = match view_state.keyed_states.get(&buffer_id) {
819            Some(bs) => bs,
820            None => return,
821        };
822
823        // Capture the current state
824        let primary_cursor = buf_state.cursors.primary();
825        let file_state = SerializedFileState {
826            cursor: SerializedCursor {
827                position: primary_cursor.position,
828                anchor: primary_cursor.anchor,
829                sticky_column: primary_cursor.sticky_column,
830            },
831            additional_cursors: buf_state
832                .cursors
833                .iter()
834                .skip(1)
835                .map(|(_, cursor)| SerializedCursor {
836                    position: cursor.position,
837                    anchor: cursor.anchor,
838                    sticky_column: cursor.sticky_column,
839                })
840                .collect(),
841            scroll: SerializedScroll {
842                top_byte: buf_state.viewport.top_byte,
843                top_view_line_offset: buf_state.viewport.top_view_line_offset,
844                left_column: buf_state.viewport.left_column,
845            },
846            view_mode: Default::default(),
847            compose_width: None,
848            plugin_state: std::collections::HashMap::new(),
849            folds: Vec::new(),
850        };
851
852        // Save to disk
853        PersistedFileWorkspace::save(&abs_path, file_state);
854        tracing::debug!("Saved file state on close for {:?}", abs_path);
855    }
856
857    /// Open the file an LSP response URI points at, handling the three
858    /// cases the goto-def / references / workspace-edit handlers all
859    /// have to think about:
860    ///
861    ///   * **on-host file** (the workspace bind mount, or a local
862    ///     authority): host-translate the URI and open the host file
863    ///     normally — exactly what the editor has always done.
864    ///   * **container-only file** (devcontainer attach with the
865    ///     target outside the workspace mount, e.g. a pip-installed
866    ///     `~/.local/.../site-packages/flask/app.py`): fetch the file
867    ///     bytes via the authority's process spawner
868    ///     (`docker exec <id> cat <path>`) and open them as a
869    ///     read-only buffer at the in-container path.
870    ///   * **unreachable** (no file at the host path; container fetch
871    ///     failed or no container authority): return `Err` so the
872    ///     caller can surface a user-visible status message instead
873    ///     of silently opening a phantom buffer.
874    ///
875    /// Cursor placement, focus, and any post-open hook firing are the
876    /// caller's job (this method just resolves "URI → BufferId").
877    pub(crate) fn open_lsp_uri_target(
878        &mut self,
879        uri: &crate::app::types::LspUri,
880    ) -> anyhow::Result<BufferId> {
881        let translation = self.authority.path_translation.clone();
882        let host_path = uri
883            .to_host_path(translation.as_ref())
884            .ok_or_else(|| anyhow::anyhow!("URI is not a file path"))?;
885
886        // Case 1: file is reachable on the host filesystem (either
887        // local authority, or workspace-mounted on a devcontainer).
888        // `open_file` focuses, which is what callers (goto-def,
889        // workspace edits) expect — they want the cursor to land in
890        // the destination buffer afterward.
891        if self.authority.filesystem.exists(&host_path) {
892            return self.open_file(&host_path);
893        }
894
895        // Case 2: container-only fetch. Only meaningful when the
896        // active authority can route a `cat` through to the
897        // container — `path_translation` being set is the proxy for
898        // "this is a container authority". Local + SSH authorities
899        // skip straight to the error case.
900        if translation.is_some() {
901            // The container-side path is the URI's raw path. Calling
902            // `to_host_path` with `None` returns the wire-side path
903            // verbatim (no translation applied) — exactly what we
904            // need for `cat <path>` inside the container.
905            let container_path = uri.to_host_path(None).ok_or_else(|| {
906                anyhow::anyhow!("URI is not a file path (container-side decode failed)")
907            })?;
908            let buffer_id = self.fetch_and_open_container_file(container_path, uri.clone())?;
909            // Match `open_file`'s focus behaviour so the cursor
910            // assertion in callers (goto-def's `MoveCursor` event)
911            // applies to the right buffer.
912            self.set_active_buffer(buffer_id);
913            return Ok(buffer_id);
914        }
915
916        // Case 3: nothing we can open.
917        Err(anyhow::anyhow!(
918            "could not open {}: file not found",
919            host_path.display()
920        ))
921    }
922
923    /// Run `cat <container_path>` through the active authority's
924    /// process spawner and open the result as a read-only buffer
925    /// tagged with the wire URI. Helper for [`Self::open_lsp_uri_target`].
926    ///
927    /// On `cat` exit-code 0 the bytes become the buffer's contents.
928    /// On any error (no tokio runtime, spawner failure, non-zero
929    /// exit) we return `Err` with a message that includes the
930    /// container path and stderr's first line — enough for the
931    /// caller's status-line surface.
932    fn fetch_and_open_container_file(
933        &mut self,
934        container_path: std::path::PathBuf,
935        uri: crate::app::types::LspUri,
936    ) -> anyhow::Result<BufferId> {
937        let runtime = self.tokio_runtime.as_ref().ok_or_else(|| {
938            anyhow::anyhow!(
939                "could not open {}: no tokio runtime available for container fetch",
940                container_path.display()
941            )
942        })?;
943
944        let spawner = self.authority.process_spawner.clone();
945        let path_arg = container_path.to_string_lossy().into_owned();
946        let result = runtime
947            .block_on(spawner.spawn("cat".into(), vec![path_arg], None))
948            .map_err(|e| {
949                anyhow::anyhow!(
950                    "could not open {} from container: {}",
951                    container_path.display(),
952                    e
953                )
954            })?;
955
956        if result.exit_code != 0 {
957            let first_stderr_line = result
958                .stderr
959                .lines()
960                .next()
961                .unwrap_or("(no error message)")
962                .trim();
963            anyhow::bail!(
964                "could not open {} from container: {}",
965                container_path.display(),
966                first_stderr_line
967            );
968        }
969
970        self.open_container_only_file(container_path, uri, result.stdout.into_bytes())
971    }
972
973    /// Build a buffer from already-fetched container content. The
974    /// buffer's `file_path` is the in-container path (so further LSP
975    /// requests carry the right URI) and the buffer is read-only —
976    /// there is no host writeback path for files that exist only
977    /// inside the container. LSP stays enabled so a follow-up
978    /// goto-def from the fetched buffer works.
979    pub(crate) fn open_container_only_file(
980        &mut self,
981        container_path: std::path::PathBuf,
982        uri: crate::app::types::LspUri,
983        content: Vec<u8>,
984    ) -> anyhow::Result<BufferId> {
985        // Don't double-open. The file_path matches by container path,
986        // since that's what we set after build.
987        let already_open = self
988            .buffers
989            .iter()
990            .find(|(_, state)| state.buffer.file_path() == Some(container_path.as_path()))
991            .map(|(id, _)| *id);
992        if let Some(id) = already_open {
993            return Ok(id);
994        }
995
996        // Build the buffer from the fetched bytes and pin its
997        // file_path to the container path. The host filesystem ref
998        // here is mostly cosmetic — the buffer is read-only so save
999        // never runs through it.
1000        let mut buffer = crate::model::buffer::Buffer::from_bytes(
1001            content,
1002            Arc::clone(&self.authority.filesystem),
1003        );
1004        buffer.rename_file_path(container_path.clone());
1005
1006        // Detect language from the container path (the basename's
1007        // extension is what matters; the directory tree is
1008        // container-side and won't match host-relative globs anyway).
1009        let first_line = buffer.first_line_lossy();
1010        let detected =
1011            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
1012                &container_path,
1013                first_line.as_deref(),
1014                &self.grammar_registry,
1015                &self.config.languages,
1016                self.config.default_language.as_deref(),
1017            );
1018        let mut state = EditorState::from_buffer_with_language(buffer, detected);
1019        state.editing_disabled = true;
1020
1021        // Whitespace / tab settings — same shape as `open_file_no_focus`
1022        // so the rendered look is consistent. Container-fetched
1023        // buffers should obey the user's editor config like any other
1024        // read-only buffer.
1025        let mut whitespace =
1026            crate::config::WhitespaceVisibility::from_editor_config(&self.config.editor);
1027        if let Some(lang_config) = self.config.languages.get(&state.language) {
1028            whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
1029            state.buffer_settings.use_tabs =
1030                lang_config.use_tabs.unwrap_or(self.config.editor.use_tabs);
1031            state.buffer_settings.tab_size =
1032                lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
1033        } else {
1034            state.buffer_settings.tab_size = self.config.editor.tab_size;
1035            state.buffer_settings.use_tabs = self.config.editor.use_tabs;
1036        }
1037        state.buffer_settings.whitespace = whitespace;
1038        state
1039            .margins
1040            .configure_for_line_numbers(self.config.editor.line_numbers);
1041
1042        let buffer_id = BufferId(self.next_buffer_id);
1043        self.next_buffer_id += 1;
1044        self.buffers.insert(buffer_id, state);
1045        self.event_logs
1046            .insert(buffer_id, crate::model::event::EventLog::new());
1047
1048        let mut metadata =
1049            super::types::BufferMetadata::with_container_file(container_path.clone(), uri);
1050        // Notify the LSP servers about the newly opened file so
1051        // hover / further goto-def in the fetched buffer works. The
1052        // URI we cached is already the wire-form URI, so the LSP
1053        // sees the right path.
1054        self.notify_lsp_file_opened(&container_path, buffer_id, &mut metadata);
1055        self.buffer_metadata.insert(buffer_id, metadata);
1056
1057        // Wire the buffer into a tab on the preferred split, mirroring
1058        // the host-file path. Skip `watch_file` — there's no host
1059        // file to inotify, and the spawned-fetch is one-shot.
1060        let target_split = self.preferred_split_for_file();
1061        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
1062        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
1063        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
1064            view_state.add_buffer(buffer_id);
1065            let buf_state = view_state.ensure_buffer_state(buffer_id);
1066            buf_state.apply_config_defaults(
1067                self.config.editor.line_numbers,
1068                self.config.editor.highlight_current_line,
1069                line_wrap,
1070                self.config.editor.wrap_indent,
1071                wrap_column,
1072                self.config.editor.rulers.clone(),
1073            );
1074        }
1075
1076        Ok(buffer_id)
1077    }
1078}