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