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