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