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