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