fresh/app/window_actions.rs
1//! Editor methods for window lifecycle (create, switch, close).
2//!
3//! Windows are introduced in
4//! `docs/internal/orchestrator-sessions-design.md`. After Step 0b each
5//! window owns its file tree, file mod-times, LSP set, panel-id
6//! map, and split layout outright. `set_active_window` is therefore
7//! a pointer write (plus seed-buffer allocation when diving into a
8//! never-activated window) — there are no warm-swap stashes left to
9//! shuffle. Plugins that listen for `active_window_changed` see the
10//! same hook sequence as before.
11
12use crate::app::window::Window;
13use crate::app::window_resources::WindowResources;
14use crate::services::plugins::hooks::HookArgs;
15use crate::view::split::{SplitManager, SplitViewState};
16use fresh_core::WindowId;
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20impl crate::app::Editor {
21 /// Snapshot the editor-global resources every new `Window` needs.
22 /// All fields are cheap clones (`Arc` increments or `Clone`-by-value
23 /// where the inner type already holds `Arc`s, like `Authority`).
24 /// Called by `create_window_at` and by the first-dive seed path in
25 /// `set_active_window`; also by `editor_init` for the base window.
26 pub(crate) fn window_resources(&self) -> WindowResources {
27 WindowResources {
28 config: std::sync::Arc::clone(&self.config),
29 grammar_registry: std::sync::Arc::clone(&self.grammar_registry),
30 theme_registry: std::sync::Arc::clone(&self.theme_registry),
31 theme_cache: std::sync::Arc::clone(&self.theme_cache),
32 keybindings: std::sync::Arc::clone(&self.keybindings),
33 command_registry: std::sync::Arc::clone(&self.command_registry),
34 fs_manager: std::sync::Arc::clone(&self.fs_manager),
35 local_filesystem: std::sync::Arc::clone(&self.local_filesystem),
36 buffer_id_alloc: self.buffer_id_alloc.clone(),
37 authority: self.authority.clone(),
38 time_source: std::sync::Arc::clone(&self.time_source),
39 dir_context: self.dir_context.clone(),
40 tokio_runtime: self.tokio_runtime.clone(),
41 async_bridge: self.async_bridge.clone(),
42 plugin_manager: std::sync::Arc::clone(&self.plugin_manager),
43 theme: std::sync::Arc::clone(&self.theme),
44 event_broadcaster: self.event_broadcaster.clone(),
45 recovery_service: std::sync::Arc::clone(&self.recovery_service),
46 }
47 }
48
49 /// Allocate a session id, insert a new `Session`, fire
50 /// `session_created`. Does not switch active.
51 ///
52 /// Caller is responsible for ensuring `root` is absolute. The
53 /// `PluginCommand::CreateWindow` dispatcher rejects relative
54 /// paths before reaching here.
55 ///
56 /// Find an existing window whose root resolves to the same
57 /// canonical directory, if any. Backs the one-session-per-dir
58 /// invariant: opening a directory that already has a window
59 /// reuses it rather than creating a duplicate.
60 pub(crate) fn find_window_by_root(&self, root: &std::path::Path) -> Option<WindowId> {
61 let key = crate::app::orchestrator_persistence::canonical_key(root);
62 self.windows
63 .iter()
64 .find(|(_, w)| crate::app::orchestrator_persistence::canonical_key(&w.root) == key)
65 .map(|(id, _)| *id)
66 }
67
68 /// Open the window for `root`, creating it if absent. Enforces
69 /// one-session-per-directory: if a window already exists at the
70 /// same canonical root it is returned as-is and `label` is
71 /// ignored (the existing window keeps its label) — no duplicate
72 /// is created.
73 ///
74 /// Seeds a freshly created window with an empty scratch buffer +
75 /// a minimal split layout up front (same shape as the first-dive
76 /// seed path), so the window is renderable immediately. Without
77 /// this, never-dived windows have `splits == None` and any
78 /// cross-window render (e.g. the Orchestrator preview pane's
79 /// `WindowEmbed`) draws blank.
80 pub fn create_window_at(&mut self, root: PathBuf, label: String) -> WindowId {
81 // One session per directory: reuse an existing window at this
82 // root instead of spawning a colliding duplicate.
83 if let Some(existing) = self.find_window_by_root(&root) {
84 return existing;
85 }
86 let id = WindowId(self.next_window_id);
87 self.next_window_id += 1;
88
89 let resources = self.window_resources();
90 let mut session = Window::new(id, label, root.clone(), resources);
91 session.terminal_width = self.terminal_width;
92 session.terminal_height = self.terminal_height;
93 let resolved_label = session.label.clone();
94 self.windows.insert(id, session);
95
96 // Same seed shape that `set_active_window` builds on
97 // first dive — installed eagerly so the window is
98 // immediately renderable from any code path that walks
99 // the windows map (preview rendering, embedded session
100 // panes, etc.).
101 if let Some((buf, state, metadata, event_log, mgr, vs)) =
102 self.build_fresh_layout_if_needed(id)
103 {
104 if let Some(s) = self.windows.get_mut(&id) {
105 s.buffers.set_splits((mgr, vs));
106 s.buffers.insert(buf, state);
107 s.buffer_metadata.insert(buf, metadata);
108 s.event_logs.insert(buf, event_log);
109 }
110 }
111
112 self.plugin_manager.read().unwrap().run_hook(
113 "window_created",
114 HookArgs::WindowCreated {
115 id: id.0,
116 label: resolved_label,
117 root: root.to_string_lossy().into_owned(),
118 },
119 );
120
121 id
122 }
123
124 /// Atomic "create a new window seeded with an agent terminal"
125 /// entry point. Used by Orchestrator's new-session flow.
126 ///
127 /// Unlike `create_window_at`, this path deliberately does NOT
128 /// seed an empty `[No Name]` buffer up front — the terminal
129 /// becomes the window's seed via `create_plugin_terminal`'s
130 /// no-active-split branch, so the new window is born with a
131 /// single tab (the terminal) instead of `[No Name] | <agent>`.
132 ///
133 /// The eager-seed invariant `create_window_at` upholds
134 /// ("window is renderable immediately after returning") still
135 /// holds here: the call to `create_plugin_terminal` runs
136 /// synchronously on the same thread before this function
137 /// yields, installing the terminal-rooted split layout before
138 /// any other code can observe the window. The `window_created`
139 /// hook is intentionally fired *after* the terminal is wired
140 /// up so plugin handlers see the new window in its final
141 /// shape, not the half-built intermediate state.
142 ///
143 /// `root` must be absolute; the plugin-command dispatcher
144 /// validates this before reaching here.
145 pub fn create_window_with_terminal(
146 &mut self,
147 root: PathBuf,
148 label: String,
149 cwd: Option<PathBuf>,
150 command: Option<Vec<String>>,
151 title: Option<String>,
152 ) -> Result<(WindowId, fresh_core::TerminalId, fresh_core::BufferId), String> {
153 let id = WindowId(self.next_window_id);
154 self.next_window_id += 1;
155
156 let resources = self.window_resources();
157 let mut session = Window::new(id, label, root.clone(), resources);
158 session.terminal_width = self.terminal_width;
159 session.terminal_height = self.terminal_height;
160 let resolved_label = session.label.clone();
161 self.windows.insert(id, session);
162
163 // Dive into the new window before spawning the terminal
164 // so `Window::create_plugin_terminal` operates on a window
165 // with `splits.is_none()` — that's the "no active_split"
166 // branch which seeds the layout rooted at the terminal
167 // buffer. We bypass `set_active_window`'s
168 // `build_fresh_layout_if_needed` call (which would install
169 // a `[No Name]` seed) by writing the active-window pointer
170 // directly.
171 let previous_id = self.active_window;
172 self.active_window = id;
173
174 let spawn_result = {
175 let target = self
176 .windows
177 .get_mut(&id)
178 .expect("just-inserted window must be present");
179 target.create_plugin_terminal(
180 cwd.or_else(|| Some(root.clone())),
181 None, // no split direction — let the no-layout branch seed
182 None,
183 true, // focus — newly spawned terminal is the seed
184 false, // ephemeral by default; orchestrator owns persistence
185 command,
186 title.filter(|t| !t.is_empty()),
187 )
188 };
189
190 let (terminal_id, buffer_id, _split_id) = match spawn_result {
191 Ok(triple) => triple,
192 Err(e) => {
193 // Roll back: tear down the half-built window and
194 // restore the previous active pointer so the user
195 // isn't stranded on an empty window when the PTY
196 // spawn fails (missing binary, permission denied,
197 // out of PTYs, ...).
198 self.windows.remove(&id);
199 self.active_window = previous_id;
200 return Err(e);
201 }
202 };
203
204 // Register the leader pid with the new window's
205 // process_groups so window-level signal operations reach
206 // the spawned group. Mirrors `create_plugin_terminal`'s
207 // registration in the active-target path of
208 // `handle_create_terminal`, but kept here because we
209 // bypass that dispatcher.
210 if let Some(pid) = self
211 .windows
212 .get(&id)
213 .and_then(|w| w.terminal_manager.get(terminal_id))
214 .and_then(|h| h.pid())
215 {
216 let pg_label = format!("terminal #{}", terminal_id.0);
217 if let Some(win) = self.windows.get_mut(&id) {
218 win.process_groups.register(pid, pg_label);
219 }
220 }
221
222 // Resize the newly-active window's PTYs (mirrors
223 // `set_active_window`'s post-dive resize so the seeded
224 // terminal renders into the right cell rect on its first
225 // frame).
226 if let Some(win) = self.windows.get_mut(&id) {
227 win.resize_visible_terminals();
228 }
229
230 // Plugin lifecycle: fire `window_created` first, then
231 // `active_window_changed`. Order mirrors the
232 // `create_window_at` + `set_active_window` sequence the
233 // orchestrator previously chained — plugin handlers that
234 // care about either event see the same payload order.
235 self.plugin_manager.read().unwrap().run_hook(
236 "window_created",
237 HookArgs::WindowCreated {
238 id: id.0,
239 label: resolved_label,
240 root: root.to_string_lossy().into_owned(),
241 },
242 );
243 if previous_id != id {
244 self.plugin_manager.read().unwrap().run_hook(
245 "active_window_changed",
246 HookArgs::ActiveWindowChanged {
247 previous_id: Some(previous_id.0),
248 active_id: id.0,
249 },
250 );
251 }
252 #[cfg(feature = "plugins")]
253 self.update_plugin_state_snapshot();
254 #[cfg(feature = "plugins")]
255 self.plugin_manager.read().unwrap().run_hook(
256 "buffer_activated",
257 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
258 );
259
260 Ok((id, terminal_id, buffer_id))
261 }
262
263 /// Switch the active window to `id`.
264 ///
265 /// Pointer write: every per-window field
266 /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
267 /// already lives on `Window`, so flipping `active_window` is the
268 /// whole switch. Diving into a never-activated window seeds it
269 /// with a fresh empty buffer + SplitManager so the renderer
270 /// finds a populated `splits` field.
271 ///
272 /// No-op when `id` is already active. Logs and returns when
273 /// `id` is unknown — the design treats unknown ids as a plugin
274 /// bug (caller verifies with `listWindows`), not a recoverable
275 /// error worth surfacing through the channel.
276 pub fn set_active_window(&mut self, id: WindowId) {
277 if self.active_window == id {
278 return;
279 }
280 if !self.windows.contains_key(&id) {
281 tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
282 return;
283 }
284
285 let previous_id = self.active_window;
286
287 // Lazy materialization: if this window's saved workspace hasn't
288 // been restored yet, restore it now (before seeding) so the
289 // dive lands on real content rather than an empty buffer.
290 self.materialize_window(id);
291
292 // For a never-activated incoming window, allocate a fresh
293 // seed buffer + SplitManager rooted at it. The state is
294 // installed into the incoming window's `buffers` map after
295 // the active pointer moves. After a successful materialize the
296 // window already has splits, so this is a no-op.
297 let fresh_layout = self.build_fresh_layout_if_needed(id);
298
299 // Pointer write — that's the whole switch. `working_dir()`
300 // derives from the active window's root, so moving the pointer
301 // is all it takes (no separate working_dir to sync).
302 self.active_window = id;
303
304 // For a never-activated incoming window, install the freshly
305 // built layout into the window's `splits` field and attach
306 // the seed buffer.
307 if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
308 if let Some(s) = self.windows.get_mut(&id) {
309 s.buffers.set_splits((mgr, vs));
310 s.buffers.insert(buf, state);
311 s.buffer_metadata.insert(buf, metadata);
312 s.event_logs.insert(buf, event_log);
313 }
314 }
315
316 // Refresh the plugin state snapshot so `getCwd()` (and every
317 // other snapshot field) reflects the window we just switched
318 // to *before* the `active_window_changed` hook runs. Without
319 // this, plugins that read `editor.getCwd()` — Live Grep, file
320 // finders, etc. — keep targeting the previous window's project
321 // after a dive, surfacing the wrong project's files.
322 #[cfg(feature = "plugins")]
323 self.update_plugin_state_snapshot();
324
325 self.plugin_manager.read().unwrap().run_hook(
326 "active_window_changed",
327 HookArgs::ActiveWindowChanged {
328 previous_id: Some(previous_id.0),
329 active_id: id.0,
330 },
331 );
332
333 // Resize the newly-active window's visible terminal PTYs to
334 // match their dive-view split rects. Without this, a session
335 // that was just previewed in the orchestrator picker
336 // (`render_session_preview_into_rect` resizes PTYs to the
337 // embed rect — typically ~half the terminal's height) keeps
338 // drawing at that smaller size after the dive, leaving the
339 // bottom of the dive view blank until something else triggers
340 // a resize. Same applies for the inverse: dive away while a
341 // session has a small split, dive back when the window is
342 // bigger — the terminal needs the new dimensions.
343 if let Some(win) = self.windows.get_mut(&id) {
344 win.resize_visible_terminals();
345 }
346 }
347
348 /// Cycle to the next open window in the workspace.
349 ///
350 /// Windows are ordered by their numeric `WindowId` (which is
351 /// monotonically assigned by `create_window_at`), so "next"
352 /// reads in creation order with wrap-around. No-op when only
353 /// one window is open (issue #2031).
354 pub fn next_window(&mut self) {
355 self.cycle_active_window(1);
356 }
357
358 /// Cycle to the previous open window. See [`Self::next_window`]
359 /// for ordering.
360 pub fn prev_window(&mut self) {
361 self.cycle_active_window(-1);
362 }
363
364 /// Step `delta` positions through the open windows (positive =
365 /// forward, negative = backward), wrapping around at the ends.
366 /// Centralises the cycle logic shared by `next_window` and
367 /// `prev_window` so both directions stay in sync if the
368 /// underlying ordering changes (e.g. user-controlled reorder).
369 fn cycle_active_window(&mut self, delta: isize) {
370 let mut ids: Vec<WindowId> = self.windows.keys().copied().collect();
371 if ids.len() <= 1 {
372 return;
373 }
374 ids.sort_by_key(|id| id.0);
375 let current_pos = match ids.iter().position(|id| *id == self.active_window) {
376 Some(pos) => pos as isize,
377 None => 0,
378 };
379 let len = ids.len() as isize;
380 let next_pos = (((current_pos + delta) % len) + len) % len;
381 let next_id = ids[next_pos as usize];
382 self.set_active_window(next_id);
383 }
384
385 /// Build a fresh seed buffer + split layout for `id` if that
386 /// window is missing either a split tree or any buffer to back
387 /// it. Returns `None` when the window is unknown or already
388 /// populated. The caller is responsible for installing the
389 /// returned tuple into the window's fields.
390 ///
391 /// Both branches (no splits, or splits but empty buffer map)
392 /// are pathological: render walks the active buffer and would
393 /// panic at `expect("active buffer must be present")` when the
394 /// split manager points at a buffer id that isn't in
395 /// `window.buffers`.
396 ///
397 /// Factored out of `set_active_window` so other call sites that
398 /// need to populate an inert window shell can share the same
399 /// seed-construction logic.
400 pub(crate) fn build_fresh_layout_if_needed(
401 &mut self,
402 id: WindowId,
403 ) -> Option<(
404 fresh_core::BufferId,
405 crate::state::EditorState,
406 crate::app::types::BufferMetadata,
407 crate::model::event::EventLog,
408 SplitManager,
409 HashMap<crate::model::event::LeafId, SplitViewState>,
410 )> {
411 if !self
412 .windows
413 .get(&id)
414 .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
415 {
416 return None;
417 }
418 let buf = self.alloc_buffer_id();
419 let mut state = crate::state::EditorState::new(
420 self.terminal_width,
421 self.terminal_height,
422 self.config.editor.large_file_threshold_bytes as usize,
423 std::sync::Arc::clone(&self.authority.filesystem),
424 );
425 state
426 .margins
427 .configure_for_line_numbers(self.config.editor.line_numbers);
428 state
429 .buffer
430 .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
431 let metadata = crate::app::types::BufferMetadata::new();
432 let event_log = crate::model::event::EventLog::new();
433 let manager = SplitManager::new(buf);
434 let active_leaf = manager.active_split();
435 let mut view_states = HashMap::new();
436 view_states.insert(
437 active_leaf,
438 SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
439 );
440 Some((buf, state, metadata, event_log, manager, view_states))
441 }
442
443 /// Eagerly initialise an inactive session's per-session
444 /// state without diving. Useful for plugins (Orchestrator) that
445 /// want to pay the warm-up cost (file-tree walk, ignore
446 /// matcher, etc.) ahead of the user's first dive.
447 ///
448 /// In the current build this is a placeholder — file
449 /// explorer rebuilds and LSP boot still happen on first dive.
450 /// The API exists so callers don't have to be rewritten when
451 /// eager warm-up wires up later.
452 pub fn prewarm_window(&mut self, id: WindowId) {
453 if id == self.active_window {
454 return;
455 }
456 if !self.windows.contains_key(&id) {
457 tracing::warn!("prewarm_window: unknown session id {id}");
458 }
459 // Placeholder for eager warm-up of file_explorer / LSP.
460 }
461
462 /// Remove a buffer from whichever window holds it. Returns the
463 /// removed `EditorState` if the buffer was found. Step 0c: each
464 /// buffer lives in exactly one window, so this is at most one
465 /// successful removal.
466 pub(crate) fn detach_buffer_from_all_windows(
467 &mut self,
468 buffer_id: fresh_core::BufferId,
469 ) -> Option<crate::state::EditorState> {
470 for w in self.windows.values_mut() {
471 if let Some(state) = w.buffers.remove(&buffer_id) {
472 return Some(state);
473 }
474 }
475 None
476 }
477
478 /// Close a session and drop its `Session` entry. Refuses to
479 /// close the currently active session — the caller must switch
480 /// to a different session first. Refuses to close the *last*
481 /// remaining window — the editor must always host at least one.
482 ///
483 /// There is no special "base" window any more: id 1 is just the
484 /// window the editor launched into, closable like any other once
485 /// another window exists. The real invariant is "≥1 window", not
486 /// "id 1 lives forever".
487 ///
488 /// Returns `true` on success, `false` on rejection.
489 pub fn close_window(&mut self, id: WindowId) -> bool {
490 if self.windows.len() <= 1 {
491 tracing::warn!("close_window: refusing to close the last remaining window (id {id})");
492 return false;
493 }
494 if id == self.active_window {
495 tracing::warn!(
496 "close_window: refusing to close the active session (id {id}); \
497 switch first via setActiveWindow"
498 );
499 return false;
500 }
501 if self.windows.remove(&id).is_none() {
502 tracing::warn!("close_window: unknown session id {id}");
503 return false;
504 }
505
506 self.plugin_manager
507 .read()
508 .unwrap()
509 .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
510
511 true
512 }
513}