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