Skip to main content

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}