Skip to main content

fresh/app/
file_open_orchestrators.rs

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