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 // Derive the window's fs_manager from the *same* authority we hand
35 // it below, so directory listings (the file explorer) ride the
36 // window's filesystem — local or remote — instead of a stale,
37 // boot-time local one. A born-attached SSH/k8s window otherwise
38 // showed the local machine in the explorer while its terminal ran
39 // remote, because the cached fs_manager never tracked the authority.
40 // Default to a host fs_manager; callers that build a window with a
41 // non-local authority re-derive it from that authority's
42 // filesystem. The window's `authority` is set on the `Window`
43 // itself (not here in the `Clone`-fanned resources).
44 fs_manager: std::sync::Arc::new(crate::services::fs::FsManager::new(
45 std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
46 )),
47 local_filesystem: std::sync::Arc::clone(&self.local_filesystem),
48 buffer_id_alloc: self.buffer_id_alloc.clone(),
49 time_source: std::sync::Arc::clone(&self.time_source),
50 dir_context: self.dir_context.clone(),
51 tokio_runtime: self.tokio_runtime.clone(),
52 async_bridge: self.async_bridge.clone(),
53 plugin_manager: std::sync::Arc::clone(&self.plugin_manager),
54 theme: std::sync::Arc::clone(&self.theme),
55 event_broadcaster: self.event_broadcaster.clone(),
56 recovery_service: std::sync::Arc::clone(&self.recovery_service),
57 }
58 }
59
60 /// Allocate a session id, insert a new `Session`, fire
61 /// `session_created`. Does not switch active.
62 ///
63 /// Caller is responsible for ensuring `root` is absolute. The
64 /// `PluginCommand::CreateWindow` dispatcher rejects relative
65 /// paths before reaching here.
66 ///
67 /// Find an existing window whose root resolves to the same
68 /// canonical directory, if any. Backs the one-session-per-dir
69 /// invariant: opening a directory that already has a window
70 /// reuses it rather than creating a duplicate.
71 pub(crate) fn find_window_by_root(&self, root: &std::path::Path) -> Option<WindowId> {
72 let key = crate::app::orchestrator_persistence::canonical_key(root);
73 self.windows
74 .iter()
75 .find(|(_, w)| crate::app::orchestrator_persistence::canonical_key(&w.root) == key)
76 .map(|(id, _)| *id)
77 }
78
79 /// Open the window for `root`, creating it if absent. Enforces
80 /// one-session-per-directory: if a window already exists at the
81 /// same canonical root it is returned as-is and `label` is
82 /// ignored (the existing window keeps its label) — no duplicate
83 /// is created.
84 ///
85 /// Seeds a freshly created window with an empty scratch buffer +
86 /// a minimal split layout up front (same shape as the first-dive
87 /// seed path), so the window is renderable immediately. Without
88 /// this, never-dived windows have `splits == None` and any
89 /// cross-window render (e.g. the Orchestrator preview pane's
90 /// `WindowEmbed`) draws blank.
91 pub fn create_window_at(&mut self, root: PathBuf, label: String) -> WindowId {
92 // One session per directory: reuse an existing window at this
93 // root instead of spawning a colliding duplicate.
94 if let Some(existing) = self.find_window_by_root(&root) {
95 return existing;
96 }
97 let id = WindowId(self.next_window_id);
98 self.next_window_id += 1;
99
100 // A new window for `root` is its own local session with its **own**
101 // per-session trust scoped to that root — not a clone of the active
102 // session's authority/trust (which would leak a trust decision across
103 // projects). Its `fs_manager` rides the same (host) filesystem.
104 let local_authority = self.local_session_authority(&root);
105 let mut resources = self.window_resources();
106 resources.fs_manager = std::sync::Arc::new(crate::services::fs::FsManager::new(
107 std::sync::Arc::clone(&local_authority.filesystem),
108 ));
109 let mut session = Window::new(id, label, root.clone(), local_authority, resources);
110 session.terminal_width = self.terminal_width;
111 session.terminal_height = self.terminal_height;
112 let resolved_label = session.label.clone();
113 self.windows.insert(id, session);
114
115 // Same seed shape that `set_active_window` builds on
116 // first dive — installed eagerly so the window is
117 // immediately renderable from any code path that walks
118 // the windows map (preview rendering, embedded session
119 // panes, etc.).
120 if let Some((buf, state, metadata, event_log, mgr, vs)) =
121 self.build_fresh_layout_if_needed(id)
122 {
123 if let Some(s) = self.windows.get_mut(&id) {
124 s.buffers.set_splits((mgr, vs));
125 s.buffers.insert(buf, state);
126 s.buffer_metadata.insert(buf, metadata);
127 s.event_logs.insert(buf, event_log);
128 }
129 }
130
131 self.plugin_manager.read().unwrap().run_hook(
132 "window_created",
133 HookArgs::WindowCreated {
134 id: id.0,
135 label: resolved_label,
136 root: root.to_string_lossy().into_owned(),
137 },
138 );
139
140 id
141 }
142
143 /// A fresh per-session execution scope (trust + env) for `root` — the one
144 /// blessed factory. Each call mints handles owned by exactly one session,
145 /// so a trust decision or env activation in one window can never leak into
146 /// another. Every per-session window construction goes through this.
147 pub(crate) fn session_scope_for(
148 &self,
149 root: &std::path::Path,
150 ) -> crate::services::authority::SessionScope {
151 crate::services::authority::SessionScope::for_root(
152 root,
153 &self.dir_context.project_state_dir(root),
154 )
155 }
156
157 /// A fresh local authority for a brand-new session rooted at `root`, with
158 /// its **own** per-session trust + env (not clones of the active
159 /// session's) and a host filesystem. The canonical backend for the
160 /// Orchestrator's "New Session (Local)" flow: callers pass this to
161 /// [`Self::create_window_with_terminal`] so a new session for a different
162 /// project is born under its own local backend, trust, and env.
163 pub fn local_session_authority(
164 &self,
165 root: &std::path::Path,
166 ) -> crate::services::authority::Authority {
167 crate::services::authority::Authority::local_scoped(self.session_scope_for(root))
168 }
169
170 /// Atomic "create a new window seeded with an agent terminal"
171 /// entry point. Used by Orchestrator's new-session flow.
172 ///
173 /// Unlike `create_window_at`, this path deliberately does NOT
174 /// seed an empty `[No Name]` buffer up front — the terminal
175 /// becomes the window's seed via `create_plugin_terminal`'s
176 /// no-active-split branch, so the new window is born with a
177 /// single tab (the terminal) instead of `[No Name] | <agent>`.
178 ///
179 /// The eager-seed invariant `create_window_at` upholds
180 /// ("window is renderable immediately after returning") still
181 /// holds here: the call to `create_plugin_terminal` runs
182 /// synchronously on the same thread before this function
183 /// yields, installing the terminal-rooted split layout before
184 /// any other code can observe the window. The `window_created`
185 /// hook is intentionally fired *after* the terminal is wired
186 /// up so plugin handlers see the new window in its final
187 /// shape, not the half-built intermediate state.
188 ///
189 /// `root` must be absolute; the plugin-command dispatcher
190 /// validates this before reaching here.
191 ///
192 /// `authority` is the backend the new session is born under — passed
193 /// explicitly so this primitive never guesses. The Orchestrator's "New
194 /// Session (Local)" flow hands it [`Self::local_session_authority`] (a
195 /// fresh local backend sharing the editor's trust + env handles), so a
196 /// new session for a *different* project does not inherit the active
197 /// window's container/SSH/k8s backend just because that window was
198 /// focused when "+ New" was clicked. The born-attached remote-session
199 /// path (`create_remote_session_window`) passes its already-connected
200 /// backend so the new window's filesystem, LSP spawner, and terminal all
201 /// act remotely from birth.
202 ///
203 /// The editor-wide authority cache is re-pointed at the new active
204 /// window via [`Self::adopt_active_window_authority`] before returning,
205 /// so the status bar, quick-open, and the 100+ `self.authority` call
206 /// sites reflect the session the user just landed on rather than the one
207 /// they left.
208 ///
209 /// `resume` is the agent-resume argv to re-run instead of `command` if
210 /// this session is restored (Orchestrator agent-resume).
211 #[allow(clippy::too_many_arguments)]
212 pub fn create_window_with_terminal(
213 &mut self,
214 root: PathBuf,
215 label: String,
216 cwd: Option<PathBuf>,
217 command: Option<Vec<String>>,
218 title: Option<String>,
219 window_authority: crate::services::authority::Authority,
220 resume: Option<Vec<String>>,
221 ) -> Result<(WindowId, fresh_core::TerminalId, fresh_core::BufferId), String> {
222 let id = WindowId(self.next_window_id);
223 self.next_window_id += 1;
224
225 // The backend the editor was acting through before this new
226 // session — captured so `adopt_active_window_authority` can tell
227 // whether the active authority actually changed and skip the
228 // hook/snapshot churn when it didn't.
229 let previous_authority_label = self.authority().display_label.clone();
230
231 let mut resources = self.window_resources();
232 // Re-derive the window's `fs_manager` from *its* backend's filesystem
233 // so the file explorer rides this session's backend, then build the
234 // window owning `window_authority` outright.
235 resources.fs_manager = std::sync::Arc::new(crate::services::fs::FsManager::new(
236 std::sync::Arc::clone(&window_authority.filesystem),
237 ));
238 let mut session = Window::new(id, label, root.clone(), window_authority, resources);
239 session.terminal_width = self.terminal_width;
240 session.terminal_height = self.terminal_height;
241 let resolved_label = session.label.clone();
242 self.windows.insert(id, session);
243
244 // Dive into the new window before spawning the terminal
245 // so `Window::create_plugin_terminal` operates on a window
246 // with `splits.is_none()` — that's the "no active_split"
247 // branch which seeds the layout rooted at the terminal
248 // buffer. We bypass `set_active_window`'s
249 // `build_fresh_layout_if_needed` call (which would install
250 // a `[No Name]` seed) by writing the active-window pointer
251 // directly.
252 let previous_id = self.active_window;
253 self.active_window = id;
254
255 // The argv to re-run if this session is restored. `None` (plain
256 // shell) is recorded as an empty vec: a present entry — even empty —
257 // marks this as a restorable *session* terminal (re-spawn it on
258 // restore), distinct from a throwaway ephemeral build/exec shell.
259 let restore_command = command.clone().unwrap_or_default();
260 let spawn_result = {
261 let target = self
262 .windows
263 .get_mut(&id)
264 .expect("just-inserted window must be present");
265 target.create_plugin_terminal(
266 cwd.or_else(|| Some(root.clone())),
267 None, // no split direction — let the no-layout branch seed
268 None,
269 true, // focus — newly spawned terminal is the seed
270 false, // ephemeral by default; orchestrator owns persistence
271 command,
272 title.filter(|t| !t.is_empty()),
273 )
274 };
275
276 let (terminal_id, buffer_id, _split_id) = match spawn_result {
277 Ok(triple) => triple,
278 Err(e) => {
279 // Roll back: tear down the half-built window and
280 // restore the previous active pointer so the user
281 // isn't stranded on an empty window when the PTY
282 // spawn fails (missing binary, permission denied,
283 // out of PTYs, ...).
284 self.windows.remove(&id);
285 self.active_window = previous_id;
286 return Err(e);
287 }
288 };
289
290 // Mark the freshly-spawned agent terminal restorable so workspace
291 // capture persists it (with its command) and a later launch
292 // re-runs it, instead of the session coming back as a blank pane.
293 // An explicit `resume` argv (agent-resume) supersedes the launch
294 // command on restore — see `restore_terminal_from_workspace`.
295 if let Some(target) = self.windows.get_mut(&id) {
296 target
297 .terminal_commands
298 .insert(terminal_id, restore_command);
299 if let Some(resume_argv) = resume.filter(|a| !a.is_empty()) {
300 target
301 .terminal_resume_commands
302 .insert(terminal_id, resume_argv);
303 }
304 }
305
306 // The switch has now committed (the spawn succeeded and the active
307 // pointer stays on the new window). This path wrote `active_window`
308 // directly above, bypassing `set_active_window` — so mirror its
309 // guard here, or a panel-scoped mode set on the window we switched
310 // away from (e.g. the New-Session form's `orchestrator-new-form`,
311 // still mounted during a born-attached SSH/K8s attach) is left
312 // stranded and silently swallows all of that window's buffer input.
313 // See #2237 / #2234 item 4.
314 self.clear_panel_scoped_mode_on_switch_away(previous_id);
315
316 // Adopt the new active window's authority into the editor-wide
317 // caches (`self.authority`, quick-open, the `authority_changed`
318 // hook). This path writes `active_window` directly and bypasses
319 // `set_active_window`, so without this the status bar + the 100+
320 // `self.authority` call sites keep reporting the *previous*
321 // window's backend — e.g. a new local session created from a
322 // devcontainer window would still show `Container:…` and route
323 // file ops through the container. The window's own
324 // `resources.authority` was already set above (local by default,
325 // or the explicit remote backend for born-attached sessions).
326 self.adopt_active_window_authority(&previous_authority_label);
327
328 // Register the leader pid with the new window's
329 // process_groups so window-level signal operations reach
330 // the spawned group. Mirrors `create_plugin_terminal`'s
331 // registration in the active-target path of
332 // `handle_create_terminal`, but kept here because we
333 // bypass that dispatcher.
334 if let Some(pid) = self
335 .windows
336 .get(&id)
337 .and_then(|w| w.terminal_manager.get(terminal_id))
338 .and_then(|h| h.pid())
339 {
340 let pg_label = format!("terminal #{}", terminal_id.0);
341 if let Some(win) = self.windows.get_mut(&id) {
342 win.process_groups.register(pid, pg_label);
343 }
344 }
345
346 // Size the newly-created window's PTYs (mirrors
347 // `set_active_window`'s post-dive resize so the seeded terminal
348 // renders into the right cell rect on its first frame). Route
349 // through the funnel rather than `win.resize_visible_terminals()`
350 // directly: a brand-new window's `dock_cols` cache is still 0, and
351 // `relayout` pushes the current editor-global dock width into every
352 // window before sizing, so the seeded terminal accounts for a dock
353 // that's already showing.
354 self.relayout();
355
356 // Plugin lifecycle: fire `window_created` first, then
357 // `active_window_changed`. Order mirrors the
358 // `create_window_at` + `set_active_window` sequence the
359 // orchestrator previously chained — plugin handlers that
360 // care about either event see the same payload order.
361 self.plugin_manager.read().unwrap().run_hook(
362 "window_created",
363 HookArgs::WindowCreated {
364 id: id.0,
365 label: resolved_label,
366 root: root.to_string_lossy().into_owned(),
367 },
368 );
369 if previous_id != id {
370 self.plugin_manager.read().unwrap().run_hook(
371 "active_window_changed",
372 HookArgs::ActiveWindowChanged {
373 previous_id: Some(previous_id.0),
374 active_id: id.0,
375 },
376 );
377 }
378 #[cfg(feature = "plugins")]
379 self.update_plugin_state_snapshot();
380 #[cfg(feature = "plugins")]
381 self.plugin_manager.read().unwrap().run_hook(
382 "buffer_activated",
383 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
384 );
385
386 Ok((id, terminal_id, buffer_id))
387 }
388
389 /// Clear a floating-panel-scoped editor mode on the window we are
390 /// switching *away* from.
391 ///
392 /// A plugin-defined editor mode (`editor.setEditorMode`) tied to a mounted
393 /// floating widget panel — the Orchestrator picker (`orchestrator-open`) or
394 /// new-session form (`orchestrator-new-form`) — is transient UI state that
395 /// belongs to the *panel*, not to the window it was opened over.
396 /// `setEditorMode` writes to whatever window is active when the plugin
397 /// calls it, so a plugin that switches the active window while its panel is
398 /// still mounted (the orchestrator "dive": `setActiveWindow(target)` first,
399 /// then `closeOpenDialog()` / `closeForm()` which runs
400 /// `setEditorMode(null)`) lands the clear on the *incoming* window and
401 /// leaves the *outgoing* one stuck in the panel's mode. That stuck mode
402 /// stays masked while the window sits in terminal mode, then silently
403 /// swallows every printable key the moment the user leaves terminal mode
404 /// (e.g. opens a file via quick-open) — the buffer ignores all keyboard
405 /// input until the user switches sessions.
406 ///
407 /// Both window-switch paths must call this before moving the active
408 /// pointer: the ordinary `set_active_window` dive *and* the born-attached
409 /// remote session creation (`create_window_with_terminal`), which writes
410 /// the active pointer directly and so never reaches `set_active_window`'s
411 /// own guard. See #2237 / #2234 item 4.
412 ///
413 /// vi-mode and other persistent per-window modes are unaffected: they never
414 /// have a floating panel mounted during a window switch.
415 fn clear_panel_scoped_mode_on_switch_away(&mut self, previous_id: WindowId) {
416 if self.floating_widget_panel.is_some() {
417 if let Some(win) = self.windows.get_mut(&previous_id) {
418 win.editor_mode = None;
419 }
420 }
421 }
422
423 /// Switch the active window to `id`.
424 ///
425 /// Pointer write: every per-window field
426 /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
427 /// already lives on `Window`, so flipping `active_window` is the
428 /// whole switch. Diving into a never-activated window seeds it
429 /// with a fresh empty buffer + SplitManager so the renderer
430 /// finds a populated `splits` field.
431 ///
432 /// No-op when `id` is already active. Logs and returns when
433 /// `id` is unknown — the design treats unknown ids as a plugin
434 /// bug (caller verifies with `listWindows`), not a recoverable
435 /// error worth surfacing through the channel.
436 pub fn set_active_window(&mut self, id: WindowId) {
437 if self.active_window == id {
438 return;
439 }
440 if !self.windows.contains_key(&id) {
441 tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
442 return;
443 }
444
445 let previous_id = self.active_window;
446 // Capture the outgoing backend label so we can tell, after the
447 // switch, whether the active *authority* actually changed (most
448 // window switches are between same-authority local sessions, where
449 // it doesn't). Only then do we re-point editor-wide caches + fire
450 // the `authority_changed` hook.
451 let previous_authority_label = self.authority().display_label.clone();
452
453 // Clear any panel-scoped editor mode on the window we're leaving so
454 // it can never outlive the switch (see
455 // `clear_panel_scoped_mode_on_switch_away`).
456 self.clear_panel_scoped_mode_on_switch_away(previous_id);
457
458 // Lazy materialization: if this window's saved workspace hasn't
459 // been restored yet, restore it now (before seeding) so the
460 // dive lands on real content rather than an empty buffer.
461 self.materialize_window(id);
462
463 // For a never-activated incoming window, allocate a fresh
464 // seed buffer + SplitManager rooted at it. The state is
465 // installed into the incoming window's `buffers` map after
466 // the active pointer moves. After a successful materialize the
467 // window already has splits, so this is a no-op.
468 let fresh_layout = self.build_fresh_layout_if_needed(id);
469
470 // Pointer write — that's the whole switch. `working_dir()`
471 // derives from the active window's root, so moving the pointer
472 // is all it takes (no separate working_dir to sync).
473 self.active_window = id;
474
475 // For a never-activated incoming window, install the freshly
476 // built layout into the window's `splits` field and attach
477 // the seed buffer.
478 if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
479 if let Some(s) = self.windows.get_mut(&id) {
480 s.buffers.set_splits((mgr, vs));
481 s.buffers.insert(buf, state);
482 s.buffer_metadata.insert(buf, metadata);
483 s.event_logs.insert(buf, event_log);
484 }
485 }
486
487 // Authority follows the active window. Each `Window` owns its
488 // `resources.authority`; the editor-wide `self.authority` cache (read
489 // by the 100+ filesystem/spawn/terminal call sites) must now reflect
490 // the window we just switched to, or a per-session remote/cloud
491 // backend would silently keep acting through the previous window's
492 // authority. This is the switch-time counterpart to
493 // `set_session_authority` (which mirrors on swap of the *active*
494 // window) — see `AUTHORITY_DESIGN.md` §"Evolution: per-session
495 // authority". Cheap for the common case: same-authority local windows
496 // share `Arc`s and the label is unchanged, so the hook below is
497 // skipped.
498 self.adopt_active_window_authority(&previous_authority_label);
499
500 // If we just switched to a remote session that came back from disk
501 // dormant (backend spec known, live authority still the local
502 // placeholder), start reconnecting its backend now — the per-window
503 // activation the per-session design calls for. SSH/k8s reconnect from
504 // core; the agent terminals re-run in the live backend once it lands.
505 #[cfg(feature = "plugins")]
506 self.reconnect_dormant_session_if_needed(id);
507
508 // Refresh the plugin state snapshot so `getCwd()` (and every
509 // other snapshot field) reflects the window we just switched
510 // to *before* the `active_window_changed` hook runs. Without
511 // this, plugins that read `editor.getCwd()` — Live Grep, file
512 // finders, etc. — keep targeting the previous window's project
513 // after a dive, surfacing the wrong project's files.
514 #[cfg(feature = "plugins")]
515 self.update_plugin_state_snapshot();
516
517 self.plugin_manager.read().unwrap().run_hook(
518 "active_window_changed",
519 HookArgs::ActiveWindowChanged {
520 previous_id: Some(previous_id.0),
521 active_id: id.0,
522 },
523 );
524
525 // Reflow the newly-active window's visible terminal PTYs to
526 // match their dive-view split rects. Without this, a session
527 // that was just previewed in the orchestrator picker
528 // (`render_session_preview_into_rect` resizes PTYs to the
529 // embed rect — typically ~half the terminal's height) keeps
530 // drawing at that smaller size after the dive, leaving the
531 // bottom of the dive view blank until something else triggers
532 // a resize. Same applies for the inverse: dive away while a
533 // session has a small split, dive back when the window is
534 // bigger — the terminal needs the new dimensions. Route through
535 // the funnel so the dive-target window also picks up the current
536 // editor-global dock width (its `dock_cols` cache may be stale).
537 self.relayout();
538 }
539
540 /// Switch the active window and play a directional wipe over the
541 /// editor content as the incoming window appears. The editor
542 /// content geometry is layout-driven (identical for any session),
543 /// so the outgoing window's last content rect is the right area to
544 /// animate. `capture_before_all` snapshots the previous frame (the
545 /// outgoing window) and `SlideIn` slides the new content in over it.
546 pub fn set_active_window_animated(&mut self, id: WindowId, from_edge: &str) {
547 let animate = self.active_window != id
548 && self.windows.contains_key(&id)
549 && self.config().editor.animations;
550 // Wipe the ENTIRE window — menu bar, explorer, tabs, splits, and
551 // status bar — i.e. everything to the right of the dock. That's
552 // the chrome area from the dock split, not just the buffer's
553 // content rect. The dock column itself stays put.
554 let full = ratatui::layout::Rect {
555 x: 0,
556 y: 0,
557 width: self.terminal_width,
558 height: self.terminal_height,
559 };
560 let (_dock, area) = self.compute_dock_split(full);
561 self.set_active_window(id);
562 if !animate {
563 return;
564 }
565 if area.width == 0 || area.height == 0 {
566 return;
567 }
568 use crate::view::animation::{AnimationKind, Edge};
569 let from = match from_edge {
570 "top" => Edge::Top,
571 "bottom" => Edge::Bottom,
572 "left" => Edge::Left,
573 "right" => Edge::Right,
574 _ => Edge::Bottom,
575 };
576 self.active_window_mut().animations.start(
577 area,
578 AnimationKind::SlideIn {
579 from,
580 duration: std::time::Duration::from_millis(180),
581 delay: std::time::Duration::ZERO,
582 },
583 );
584 }
585
586 /// Cycle to the next open window in the workspace.
587 ///
588 /// Windows are ordered by their numeric `WindowId` (which is
589 /// monotonically assigned by `create_window_at`), so "next"
590 /// reads in creation order with wrap-around. No-op when only
591 /// one window is open (issue #2031).
592 pub fn next_window(&mut self) {
593 self.cycle_active_window(1);
594 }
595
596 /// Cycle to the previous open window. See [`Self::next_window`]
597 /// for ordering.
598 pub fn prev_window(&mut self) {
599 self.cycle_active_window(-1);
600 }
601
602 /// Step `delta` positions through the open windows (positive =
603 /// forward, negative = backward), wrapping around at the ends.
604 /// Centralises the cycle logic shared by `next_window` and
605 /// `prev_window` so both directions stay in sync if the
606 /// underlying ordering changes (e.g. user-controlled reorder).
607 fn cycle_active_window(&mut self, delta: isize) {
608 // A plugin (the orchestrator dock) may constrain cycling to a
609 // specific ordered subset — the windows currently visible in its
610 // session list — so Next/Prev Window walks exactly that list rather
611 // than every open window. Ids no longer open are dropped, preserving
612 // the given order. An empty result (or no override) falls back to the
613 // default: every window, ordered by id.
614 let override_ids: Option<Vec<WindowId>> = self
615 .window_cycle_order
616 .as_ref()
617 .map(|order| {
618 order
619 .iter()
620 .copied()
621 .filter(|id| self.windows.contains_key(id))
622 .collect::<Vec<_>>()
623 })
624 .filter(|kept| !kept.is_empty());
625 let ids: Vec<WindowId> = match override_ids {
626 Some(kept) => kept,
627 None => {
628 let mut all: Vec<WindowId> = self.windows.keys().copied().collect();
629 all.sort_by_key(|id| id.0);
630 all
631 }
632 };
633 if ids.len() <= 1 {
634 return;
635 }
636 let current_pos = match ids.iter().position(|id| *id == self.active_window) {
637 Some(pos) => pos as isize,
638 None => 0,
639 };
640 let len = ids.len() as isize;
641 let next_pos = (((current_pos + delta) % len) + len) % len;
642 let next_id = ids[next_pos as usize];
643 self.set_active_window(next_id);
644 }
645
646 /// Build a fresh seed buffer + split layout for `id` if that
647 /// window is missing either a split tree or any buffer to back
648 /// it. Returns `None` when the window is unknown or already
649 /// populated. The caller is responsible for installing the
650 /// returned tuple into the window's fields.
651 ///
652 /// Both branches (no splits, or splits but empty buffer map)
653 /// are pathological: render walks the active buffer and would
654 /// panic at `expect("active buffer must be present")` when the
655 /// split manager points at a buffer id that isn't in
656 /// `window.buffers`.
657 ///
658 /// Factored out of `set_active_window` so other call sites that
659 /// need to populate an inert window shell can share the same
660 /// seed-construction logic.
661 pub(crate) fn build_fresh_layout_if_needed(
662 &mut self,
663 id: WindowId,
664 ) -> Option<(
665 fresh_core::BufferId,
666 crate::state::EditorState,
667 crate::app::types::BufferMetadata,
668 crate::model::event::EventLog,
669 SplitManager,
670 HashMap<crate::model::event::LeafId, SplitViewState>,
671 )> {
672 if !self
673 .windows
674 .get(&id)
675 .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
676 {
677 return None;
678 }
679 let buf = self.alloc_buffer_id();
680 let mut state = crate::state::EditorState::new(
681 self.terminal_width,
682 self.terminal_height,
683 self.config.editor.large_file_threshold_bytes as usize,
684 std::sync::Arc::clone(&self.authority().filesystem),
685 );
686 state
687 .margins
688 .configure_for_line_numbers(self.config.editor.line_numbers);
689 state
690 .buffer
691 .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
692 let metadata = crate::app::types::BufferMetadata::new();
693 let event_log = crate::model::event::EventLog::new();
694 let manager = SplitManager::new(buf);
695 let active_leaf = manager.active_split();
696 let mut view_states = HashMap::new();
697 view_states.insert(
698 active_leaf,
699 SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
700 );
701 Some((buf, state, metadata, event_log, manager, view_states))
702 }
703
704 /// Eagerly initialise an inactive session's per-session
705 /// state without diving. Useful for plugins (Orchestrator) that
706 /// want to pay the warm-up cost (file-tree walk, ignore
707 /// matcher, etc.) ahead of the user's first dive.
708 ///
709 /// In the current build this is a placeholder — file
710 /// explorer rebuilds and LSP boot still happen on first dive.
711 /// The API exists so callers don't have to be rewritten when
712 /// eager warm-up wires up later.
713 pub fn prewarm_window(&mut self, id: WindowId) {
714 if id == self.active_window {
715 return;
716 }
717 if !self.windows.contains_key(&id) {
718 tracing::warn!("prewarm_window: unknown session id {id}");
719 }
720 // Placeholder for eager warm-up of file_explorer / LSP.
721 }
722
723 /// Remove a buffer from whichever window holds it. Returns the
724 /// removed `EditorState` if the buffer was found. Step 0c: each
725 /// buffer lives in exactly one window, so this is at most one
726 /// successful removal.
727 pub(crate) fn detach_buffer_from_all_windows(
728 &mut self,
729 buffer_id: fresh_core::BufferId,
730 ) -> Option<crate::state::EditorState> {
731 for w in self.windows.values_mut() {
732 if let Some(state) = w.buffers.remove(&buffer_id) {
733 return Some(state);
734 }
735 }
736 None
737 }
738
739 /// Close a session and drop its `Session` entry. Refuses to
740 /// close the currently active session — the caller must switch
741 /// to a different session first. Refuses to close the *last*
742 /// remaining window — the editor must always host at least one.
743 ///
744 /// There is no special "base" window any more: id 1 is just the
745 /// window the editor launched into, closable like any other once
746 /// another window exists. The real invariant is "≥1 window", not
747 /// "id 1 lives forever".
748 ///
749 /// Returns `true` on success, `false` on rejection.
750 pub fn close_window(&mut self, id: WindowId) -> bool {
751 if self.windows.len() <= 1 {
752 tracing::warn!("close_window: refusing to close the last remaining window (id {id})");
753 return false;
754 }
755 if id == self.active_window {
756 tracing::warn!(
757 "close_window: refusing to close the active session (id {id}); \
758 switch first via setActiveWindow"
759 );
760 return false;
761 }
762 if self.windows.remove(&id).is_none() {
763 tracing::warn!("close_window: unknown session id {id}");
764 return false;
765 }
766 // Tear down a born-attached remote session's connection (carrier +
767 // reconnect/heartbeat + runtime) when its window closes. No-op for
768 // local windows, which never have an entry.
769 if self.session_keepalives.remove(&id).is_some() {
770 tracing::info!("close_window: dropped remote session keepalive for window {id}");
771 }
772
773 self.plugin_manager
774 .read()
775 .unwrap()
776 .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
777
778 true
779 }
780
781 /// Born-attached remote session: create a **new window** whose authority is
782 /// the already-connected remote backend (Kubernetes / SSH / …), seed its
783 /// terminal *inside* that backend, and park the connection `keepalive`
784 /// keyed by the window so it outlives editor rebuilds and is torn down on
785 /// close.
786 ///
787 /// Unlike the global `install_authority_with_keepalive` restart, existing
788 /// windows are left untouched — the remote session coexists with them, and
789 /// `set_active_window` (Gap A) retargets the active authority when the user
790 /// switches. The new window is born under `authority` because it is passed
791 /// straight to `create_window_with_terminal` as that window's backend, so
792 /// its filesystem, LSP spawner, and terminal wrapper all act in the backend
793 /// from birth (there are no stale local handles to invalidate — the caveat
794 /// that gates hot-swapping an *existing* window's authority doesn't apply
795 /// here). `create_window_with_terminal` adopts the new window's authority
796 /// into the editor-wide caches before returning.
797 pub(crate) fn create_remote_session_window(
798 &mut self,
799 authority: crate::services::authority::Authority,
800 keepalive: Box<dyn std::any::Any + Send>,
801 root: PathBuf,
802 label: String,
803 command: Option<Vec<String>>,
804 spec: crate::services::authority::SessionAuthoritySpec,
805 ) -> Result<WindowId, String> {
806 match self.create_window_with_terminal(
807 root.clone(),
808 label,
809 Some(root),
810 command,
811 None,
812 authority,
813 None,
814 ) {
815 Ok((window_id, _terminal, _buffer)) => {
816 self.session_keepalives.insert(window_id, keepalive);
817 // Persist how to reconnect this backend on the new session so
818 // a restart / relaunch can bring it back rather than degrade
819 // it to local.
820 if let Some(w) = self.windows.get_mut(&window_id) {
821 w.authority_spec = spec;
822 }
823 Ok(window_id)
824 }
825 Err(e) => {
826 // The connect succeeded but the window couldn't be seeded
827 // (e.g. the backend has no python3 / the pod died):
828 // `create_window_with_terminal` already rolled the active
829 // pointer back to the previous window and left the
830 // editor-wide authority untouched (it never installed the
831 // remote one), so just drop the keepalive (tears down the
832 // carrier).
833 drop(keepalive);
834 Err(e)
835 }
836 }
837 }
838}