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` resolves to a location under the app data dir.
760 /// Both sides are canonicalized so a symlinked home (e.g. macOS
761 /// `/var` → `/private/var`) doesn't defeat the prefix check.
762 fn path_under_data_dir(&self, path: &Path) -> bool {
763 let data_dir = &self.resources.dir_context.data_dir;
764 let canonical_data = self
765 .resources
766 .authority
767 .filesystem
768 .canonicalize(data_dir)
769 .unwrap_or_else(|_| data_dir.clone());
770 path.starts_with(&canonical_data)
771 }
772
773 fn open_file_no_focus_inner(
774 &mut self,
775 path: &Path,
776 allow_replace_empty: bool,
777 ) -> anyhow::Result<BufferId> {
778 // Fail fast if the remote connection is down — don't attempt I/O that
779 // would either timeout or return confusing errors.
780 if !self.resources.authority.filesystem.is_remote_connected() {
781 anyhow::bail!(
782 "Cannot open file: remote connection lost ({})",
783 self.resources
784 .authority
785 .filesystem
786 .remote_connection_info()
787 .unwrap_or("unknown host")
788 );
789 }
790
791 // Resolve relative paths against appropriate base directory.
792 // For remote mode, use the remote home directory; for local, use
793 // this window's root.
794 let base_dir = if self
795 .resources
796 .authority
797 .filesystem
798 .remote_connection_info()
799 .is_some()
800 {
801 self.resources
802 .authority
803 .filesystem
804 .home_dir()
805 .unwrap_or_else(|_| self.root.clone())
806 } else {
807 self.root.clone()
808 };
809
810 let resolved_path = if path.is_relative() {
811 base_dir.join(path)
812 } else {
813 path.to_path_buf()
814 };
815
816 // Determine if we're opening a non-existent file (for creating new files)
817 // Use filesystem trait method to support remote files
818 let file_exists = self.resources.authority.filesystem.exists(&resolved_path);
819
820 // Save the user-visible (non-canonicalized) path for language detection.
821 // Glob patterns in language config should match the path as the user sees it,
822 // not the canonical path (e.g., on macOS /var -> /private/var symlinks).
823 let display_path = resolved_path.clone();
824
825 // Canonicalize the path to resolve symlinks and normalize path components
826 // This ensures consistent path representation throughout the editor
827 // For non-existent files, we need to canonicalize the parent directory and append the filename
828 let canonical_path = if file_exists {
829 self.resources
830 .authority
831 .filesystem
832 .canonicalize(&resolved_path)
833 .unwrap_or_else(|_| resolved_path.clone())
834 } else {
835 // For non-existent files, canonicalize parent dir and append filename
836 if let Some(parent) = resolved_path.parent() {
837 let canonical_parent = if parent.as_os_str().is_empty() {
838 // No parent means just a filename, use base dir
839 base_dir.clone()
840 } else {
841 self.resources
842 .authority
843 .filesystem
844 .canonicalize(parent)
845 .unwrap_or_else(|_| parent.to_path_buf())
846 };
847 if let Some(filename) = resolved_path.file_name() {
848 canonical_parent.join(filename)
849 } else {
850 resolved_path
851 }
852 } else {
853 resolved_path
854 }
855 };
856 let path = canonical_path.as_path();
857
858 // Check if the path is a directory (after following symlinks via canonicalize)
859 // Directories cannot be opened as files in the editor
860 // Use filesystem trait method to support remote files
861 if self
862 .resources
863 .authority
864 .filesystem
865 .is_dir(path)
866 .unwrap_or(false)
867 {
868 anyhow::bail!(t!("buffer.cannot_open_directory"));
869 }
870
871 // Check if file is already open - return existing buffer without switching
872 let already_open = self
873 .buffers
874 .iter()
875 .find(|(_, state)| state.buffer.file_path() == Some(path))
876 .map(|(id, _)| *id);
877
878 if let Some(id) = already_open {
879 return Ok(id);
880 }
881
882 // If the current buffer is empty and unmodified, replace it instead of creating a new one
883 // Note: Don't replace composite buffers (they appear empty but are special views).
884 // Suppressed when `allow_replace_empty` is false — see
885 // `open_file_for_preview` for the rationale.
886 let replace_current = allow_replace_empty && {
887 let current_state = self.buffers.get(&self.active_buffer()).unwrap();
888 !current_state.is_composite_buffer
889 && current_state.buffer.is_empty()
890 && !current_state.buffer.is_modified()
891 && current_state.buffer.file_path().is_none()
892 };
893
894 let buffer_id = if replace_current {
895 // Reuse the current empty buffer
896 self.active_buffer()
897 } else {
898 // Create new buffer for this file
899 self.alloc_buffer_id()
900 };
901
902 // Create the editor state - either load from file or create empty buffer
903 tracing::info!(
904 "[SYNTAX DEBUG] open_file_no_focus: path={:?}, extension={:?}, catalog={}",
905 path,
906 path.extension(),
907 self.resources.grammar_registry.catalog().len(),
908 );
909 let mut state = if file_exists {
910 // Load from canonical path (for I/O and dedup), detect language from
911 // display path (for glob pattern matching against user-visible names).
912 let buffer = crate::model::buffer::Buffer::load_from_file(
913 &canonical_path,
914 self.resources.config.editor.large_file_threshold_bytes as usize,
915 Arc::clone(&self.resources.authority.filesystem),
916 )?;
917 let first_line = buffer.first_line_lossy();
918 let detected =
919 crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
920 &display_path,
921 first_line.as_deref(),
922 &self.resources.grammar_registry,
923 &self.resources.config.languages,
924 self.resources.config.default_language.as_deref(),
925 );
926 EditorState::from_buffer_with_language(buffer, detected)
927 } else {
928 // File doesn't exist - create empty buffer with the file path set
929 EditorState::new_with_path(
930 self.resources.config.editor.large_file_threshold_bytes as usize,
931 Arc::clone(&self.resources.authority.filesystem),
932 path.to_path_buf(),
933 )
934 };
935 // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
936
937 // Check if the buffer contains binary content
938 let is_binary = state.buffer.is_binary();
939 if is_binary {
940 // Make binary buffers read-only
941 state.editing_disabled = true;
942 tracing::info!("Detected binary file: {}", path.display());
943 }
944
945 // Files under the app data dir (e.g. terminal scrollback backing
946 // files surfaced by Universal Search) are internal artifacts the
947 // user is inspecting, not editing — open them read-only so an
948 // accidental keystroke can't corrupt persisted state.
949 if self.path_under_data_dir(&canonical_path) {
950 state.editing_disabled = true;
951 }
952
953 // Set whitespace visibility, use_tabs, and tab_size based on language config
954 // with fallback to global editor config for tab_size
955 // Use the buffer's stored language (already set by from_file_with_languages)
956 let mut whitespace =
957 crate::config::WhitespaceVisibility::from_editor_config(&self.resources.config.editor);
958 state.buffer_settings.auto_close = self.resources.config.editor.auto_close;
959 state.buffer_settings.auto_surround = self.resources.config.editor.auto_surround;
960 if let Some(lang_config) = self.resources.config.languages.get(&state.language) {
961 whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
962 state.buffer_settings.use_tabs = lang_config
963 .use_tabs
964 .unwrap_or(self.resources.config.editor.use_tabs);
965 // Use language-specific tab_size if set, otherwise fall back to global
966 state.buffer_settings.tab_size = lang_config
967 .tab_size
968 .unwrap_or(self.resources.config.editor.tab_size);
969 // Auto close: language override (only if globally enabled)
970 if state.buffer_settings.auto_close {
971 if let Some(lang_auto_close) = lang_config.auto_close {
972 state.buffer_settings.auto_close = lang_auto_close;
973 }
974 }
975 // Auto surround: language override (only if globally enabled)
976 if state.buffer_settings.auto_surround {
977 if let Some(lang_auto_surround) = lang_config.auto_surround {
978 state.buffer_settings.auto_surround = lang_auto_surround;
979 }
980 }
981 } else {
982 state.buffer_settings.tab_size = self.resources.config.editor.tab_size;
983 state.buffer_settings.use_tabs = self.resources.config.editor.use_tabs;
984 }
985 state.buffer_settings.whitespace = whitespace;
986
987 // Apply line_numbers default from config
988 state
989 .margins
990 .configure_for_line_numbers(self.resources.config.editor.line_numbers);
991
992 self.buffers.insert(buffer_id, state);
993 self.event_logs
994 .insert(buffer_id, crate::model::event::EventLog::new());
995
996 // Create metadata for this buffer
997 let mut metadata = crate::app::types::BufferMetadata::with_file(
998 path.to_path_buf(),
999 &display_path,
1000 &self.root,
1001 self.resources.authority.path_translation.as_ref(),
1002 );
1003
1004 // Mark binary files in metadata and disable LSP
1005 if is_binary {
1006 metadata.binary = true;
1007 metadata.read_only = true;
1008 metadata.disable_lsp(t!("buffer.binary_file").to_string());
1009 }
1010
1011 // Check if the file is read-only on disk (filesystem permissions)
1012 if file_exists
1013 && !metadata.read_only
1014 && !self.resources.authority.filesystem.is_writable(path)
1015 {
1016 metadata.read_only = true;
1017 }
1018
1019 // Mark read-only files (library, binary, or filesystem-readonly) as editing-disabled
1020 if metadata.read_only {
1021 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1022 state.editing_disabled = true;
1023 }
1024 }
1025
1026 // Notify LSP about the newly opened file (skip for binary files)
1027 if !is_binary {
1028 self.notify_lsp_file_opened(path, buffer_id, &mut metadata);
1029 }
1030
1031 // Store metadata for this buffer
1032 self.buffer_metadata.insert(buffer_id, metadata);
1033
1034 // Add buffer to the preferred split's tabs (but don't switch to it)
1035 // Uses preferred_split_for_file() to avoid opening in labeled splits (e.g., sidebars)
1036 let target_split = self.preferred_split_for_file();
1037 let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
1038 let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
1039 let page_view = self.resolve_page_view_for_buffer(buffer_id);
1040 // Snapshot config values before taking the mutable view-states borrow
1041 // so the closure body doesn't have to re-borrow `self.resources`.
1042 let cfg = self.resources.config.editor.clone();
1043 if let Some(view_state) = self
1044 .split_view_states_mut()
1045 .expect("active window must have a populated split layout")
1046 .get_mut(&target_split)
1047 {
1048 view_state.add_buffer(buffer_id);
1049 // Initialize per-buffer view state for the new buffer with config defaults
1050 let buf_state = view_state.ensure_buffer_state(buffer_id);
1051 buf_state.apply_config_defaults(
1052 cfg.line_numbers,
1053 cfg.highlight_current_line,
1054 line_wrap,
1055 cfg.wrap_indent,
1056 wrap_column,
1057 cfg.rulers,
1058 );
1059 // Auto-activate page view if configured for this language
1060 if let Some(page_width) = page_view {
1061 buf_state.activate_page_view(page_width);
1062 }
1063 }
1064
1065 // Restore global file state (scroll/cursor position) if available
1066 // This persists file positions across projects and editor instances
1067 self.restore_global_file_state(buffer_id, path, target_split);
1068
1069 // Emit control event
1070 self.resources.event_broadcaster.emit_named(
1071 crate::model::control_event::events::FILE_OPENED.name,
1072 serde_json::json!({
1073 "path": path.display().to_string(),
1074 "buffer_id": buffer_id.0
1075 }),
1076 );
1077
1078 // Track file for auto-revert and conflict detection
1079 self.watch_file(path);
1080
1081 // Fire AfterFileOpen hook for plugins
1082 self.resources.plugin_manager.read().unwrap().run_hook(
1083 "after_file_open",
1084 crate::services::plugins::hooks::HookArgs::AfterFileOpen {
1085 buffer_id,
1086 path: path.to_path_buf(),
1087 },
1088 );
1089
1090 Ok(buffer_id)
1091 }
1092}