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