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