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        // Run the workspace-trust decision for the project this session opens,
256        // exactly as the CLI / session-server startup paths do — the
257        // orchestrator's "New Session" path bypasses those, so without this a
258        // never-decided project opened through the dock stays Restricted and
259        // its env manager (venv / direnv / mise) never activates, leaving the
260        // terminal below on the *system* toolchain even though a direct
261        // `fresh <dir>` would have auto-trusted and activated it. `authority()`
262        // already follows `active_window` (set just above), so this scopes to
263        // the new window's trust + root. Local only: remote (born-attached
264        // SSH/k8s) sessions manage trust through their own connect flow and
265        // their markers don't live on the host filesystem.
266        if self
267            .authority()
268            .filesystem
269            .remote_connection_info()
270            .is_none()
271        {
272            self.maybe_prompt_workspace_trust();
273        }
274
275        // The argv to re-run if this session is restored. `None` (plain
276        // shell) is recorded as an empty vec: a present entry — even empty —
277        // marks this as a restorable *session* terminal (re-spawn it on
278        // restore), distinct from a throwaway ephemeral build/exec shell.
279        let restore_command = command.clone().unwrap_or_default();
280        let spawn_result = {
281            let target = self
282                .windows
283                .get_mut(&id)
284                .expect("just-inserted window must be present");
285            target.create_plugin_terminal(
286                cwd.or_else(|| Some(root.clone())),
287                None, // no split direction — let the no-layout branch seed
288                None,
289                true,  // focus — newly spawned terminal is the seed
290                false, // ephemeral by default; orchestrator owns persistence
291                command,
292                title.filter(|t| !t.is_empty()),
293            )
294        };
295
296        let (terminal_id, buffer_id, _split_id) = match spawn_result {
297            Ok(triple) => triple,
298            Err(e) => {
299                // Roll back: tear down the half-built window and
300                // restore the previous active pointer so the user
301                // isn't stranded on an empty window when the PTY
302                // spawn fails (missing binary, permission denied,
303                // out of PTYs, ...).
304                self.windows.remove(&id);
305                self.active_window = previous_id;
306                return Err(e);
307            }
308        };
309
310        // Mark the freshly-spawned agent terminal restorable so workspace
311        // capture persists it (with its command) and a later launch
312        // re-runs it, instead of the session coming back as a blank pane.
313        // An explicit `resume` argv (agent-resume) supersedes the launch
314        // command on restore — see `restore_terminal_from_workspace`.
315        if let Some(target) = self.windows.get_mut(&id) {
316            target
317                .terminal_commands
318                .insert(terminal_id, restore_command);
319            if let Some(resume_argv) = resume.filter(|a| !a.is_empty()) {
320                target
321                    .terminal_resume_commands
322                    .insert(terminal_id, resume_argv);
323            }
324        }
325
326        // The switch has now committed (the spawn succeeded and the active
327        // pointer stays on the new window). This path wrote `active_window`
328        // directly above, bypassing `set_active_window` — so mirror its
329        // guard here, or a panel-scoped mode set on the window we switched
330        // away from (e.g. the New-Session form's `orchestrator-new-form`,
331        // still mounted during a born-attached SSH/K8s attach) is left
332        // stranded and silently swallows all of that window's buffer input.
333        // See #2237 / #2234 item 4.
334        self.clear_panel_scoped_mode_on_switch_away(previous_id);
335
336        // Adopt the new active window's authority into the editor-wide
337        // caches (`self.authority`, quick-open, the `authority_changed`
338        // hook). This path writes `active_window` directly and bypasses
339        // `set_active_window`, so without this the status bar + the 100+
340        // `self.authority` call sites keep reporting the *previous*
341        // window's backend — e.g. a new local session created from a
342        // devcontainer window would still show `Container:…` and route
343        // file ops through the container. The window's own
344        // `resources.authority` was already set above (local by default,
345        // or the explicit remote backend for born-attached sessions).
346        self.adopt_active_window_authority(&previous_authority_label);
347
348        // Register the leader pid with the new window's
349        // process_groups so window-level signal operations reach
350        // the spawned group. Mirrors `create_plugin_terminal`'s
351        // registration in the active-target path of
352        // `handle_create_terminal`, but kept here because we
353        // bypass that dispatcher.
354        if let Some(pid) = self
355            .windows
356            .get(&id)
357            .and_then(|w| w.terminal_manager.get(terminal_id))
358            .and_then(|h| h.pid())
359        {
360            let pg_label = format!("terminal #{}", terminal_id.0);
361            if let Some(win) = self.windows.get_mut(&id) {
362                win.process_groups.register(pid, pg_label);
363            }
364        }
365
366        // Size the newly-created window's PTYs (mirrors
367        // `set_active_window`'s post-dive resize so the seeded terminal
368        // renders into the right cell rect on its first frame). Route
369        // through the funnel rather than `win.resize_visible_terminals()`
370        // directly: a brand-new window's `dock_cols` cache is still 0, and
371        // `relayout` pushes the current editor-global dock width into every
372        // window before sizing, so the seeded terminal accounts for a dock
373        // that's already showing.
374        self.relayout();
375
376        // Plugin lifecycle: fire `window_created` first, then
377        // `active_window_changed`. Order mirrors the
378        // `create_window_at` + `set_active_window` sequence the
379        // orchestrator previously chained — plugin handlers that
380        // care about either event see the same payload order.
381        self.plugin_manager.read().unwrap().run_hook(
382            "window_created",
383            HookArgs::WindowCreated {
384                id: id.0,
385                label: resolved_label,
386                root: root.to_string_lossy().into_owned(),
387            },
388        );
389        if previous_id != id {
390            self.plugin_manager.read().unwrap().run_hook(
391                "active_window_changed",
392                HookArgs::ActiveWindowChanged {
393                    previous_id: Some(previous_id.0),
394                    active_id: id.0,
395                },
396            );
397        }
398        #[cfg(feature = "plugins")]
399        self.update_plugin_state_snapshot();
400        #[cfg(feature = "plugins")]
401        self.plugin_manager.read().unwrap().run_hook(
402            "buffer_activated",
403            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
404        );
405
406        Ok((id, terminal_id, buffer_id))
407    }
408
409    /// Clear a floating-panel-scoped editor mode on the window we are
410    /// switching *away* from.
411    ///
412    /// A plugin-defined editor mode (`editor.setEditorMode`) tied to a mounted
413    /// floating widget panel — the Orchestrator picker (`orchestrator-open`) or
414    /// new-session form (`orchestrator-new-form`) — is transient UI state that
415    /// belongs to the *panel*, not to the window it was opened over.
416    /// `setEditorMode` writes to whatever window is active when the plugin
417    /// calls it, so a plugin that switches the active window while its panel is
418    /// still mounted (the orchestrator "dive": `setActiveWindow(target)` first,
419    /// then `closeOpenDialog()` / `closeForm()` which runs
420    /// `setEditorMode(null)`) lands the clear on the *incoming* window and
421    /// leaves the *outgoing* one stuck in the panel's mode. That stuck mode
422    /// stays masked while the window sits in terminal mode, then silently
423    /// swallows every printable key the moment the user leaves terminal mode
424    /// (e.g. opens a file via quick-open) — the buffer ignores all keyboard
425    /// input until the user switches sessions.
426    ///
427    /// Both window-switch paths must call this before moving the active
428    /// pointer: the ordinary `set_active_window` dive *and* the born-attached
429    /// remote session creation (`create_window_with_terminal`), which writes
430    /// the active pointer directly and so never reaches `set_active_window`'s
431    /// own guard. See #2237 / #2234 item 4.
432    ///
433    /// vi-mode and other persistent per-window modes are unaffected: they never
434    /// have a floating panel mounted during a window switch.
435    fn clear_panel_scoped_mode_on_switch_away(&mut self, previous_id: WindowId) {
436        if self.floating_widget_panel.is_some() {
437            if let Some(win) = self.windows.get_mut(&previous_id) {
438                win.editor_mode = None;
439            }
440        }
441    }
442
443    /// Switch the active window to `id`.
444    ///
445    /// Pointer write: every per-window field
446    /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
447    /// already lives on `Window`, so flipping `active_window` is the
448    /// whole switch. Diving into a never-activated window seeds it
449    /// with a fresh empty buffer + SplitManager so the renderer
450    /// finds a populated `splits` field.
451    ///
452    /// No-op when `id` is already active. Logs and returns when
453    /// `id` is unknown — the design treats unknown ids as a plugin
454    /// bug (caller verifies with `listWindows`), not a recoverable
455    /// error worth surfacing through the channel.
456    pub fn set_active_window(&mut self, id: WindowId) {
457        if self.active_window == id {
458            return;
459        }
460        if !self.windows.contains_key(&id) {
461            tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
462            return;
463        }
464
465        let previous_id = self.active_window;
466        // Capture the outgoing backend label so we can tell, after the
467        // switch, whether the active *authority* actually changed (most
468        // window switches are between same-authority local sessions, where
469        // it doesn't). Only then do we re-point editor-wide caches + fire
470        // the `authority_changed` hook.
471        let previous_authority_label = self.authority().display_label.clone();
472
473        // Clear any panel-scoped editor mode on the window we're leaving so
474        // it can never outlive the switch (see
475        // `clear_panel_scoped_mode_on_switch_away`).
476        self.clear_panel_scoped_mode_on_switch_away(previous_id);
477
478        // Lazy materialization: if this window's saved workspace hasn't
479        // been restored yet, restore it now (before seeding) so the
480        // dive lands on real content rather than an empty buffer.
481        self.materialize_window(id);
482
483        // For a never-activated incoming window, allocate a fresh
484        // seed buffer + SplitManager rooted at it. The state is
485        // installed into the incoming window's `buffers` map after
486        // the active pointer moves. After a successful materialize the
487        // window already has splits, so this is a no-op.
488        let fresh_layout = self.build_fresh_layout_if_needed(id);
489
490        // Pointer write — that's the whole switch. `working_dir()`
491        // derives from the active window's root, so moving the pointer
492        // is all it takes (no separate working_dir to sync).
493        self.active_window = id;
494
495        // For a never-activated incoming window, install the freshly
496        // built layout into the window's `splits` field and attach
497        // the seed buffer.
498        if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
499            if let Some(s) = self.windows.get_mut(&id) {
500                s.buffers.set_splits((mgr, vs));
501                s.buffers.insert(buf, state);
502                s.buffer_metadata.insert(buf, metadata);
503                s.event_logs.insert(buf, event_log);
504            }
505        }
506
507        // Authority follows the active window. Each `Window` owns its
508        // `resources.authority`; the editor-wide `self.authority` cache (read
509        // by the 100+ filesystem/spawn/terminal call sites) must now reflect
510        // the window we just switched to, or a per-session remote/cloud
511        // backend would silently keep acting through the previous window's
512        // authority. This is the switch-time counterpart to
513        // `set_session_authority` (which mirrors on swap of the *active*
514        // window) — see `AUTHORITY_DESIGN.md` §"Evolution: per-session
515        // authority". Cheap for the common case: same-authority local windows
516        // share `Arc`s and the label is unchanged, so the hook below is
517        // skipped.
518        self.adopt_active_window_authority(&previous_authority_label);
519
520        // If we just switched to a remote session that came back from disk
521        // dormant (backend spec known, live authority still the local
522        // placeholder), start reconnecting its backend now — the per-window
523        // activation the per-session design calls for. SSH/k8s reconnect from
524        // core; the agent terminals re-run in the live backend once it lands.
525        #[cfg(feature = "plugins")]
526        self.reconnect_dormant_session_if_needed(id);
527
528        // Refresh the plugin state snapshot so `getCwd()` (and every
529        // other snapshot field) reflects the window we just switched
530        // to *before* the `active_window_changed` hook runs. Without
531        // this, plugins that read `editor.getCwd()` — Live Grep, file
532        // finders, etc. — keep targeting the previous window's project
533        // after a dive, surfacing the wrong project's files.
534        #[cfg(feature = "plugins")]
535        self.update_plugin_state_snapshot();
536
537        self.plugin_manager.read().unwrap().run_hook(
538            "active_window_changed",
539            HookArgs::ActiveWindowChanged {
540                previous_id: Some(previous_id.0),
541                active_id: id.0,
542            },
543        );
544
545        // Reflow the newly-active window's visible terminal PTYs to
546        // match their dive-view split rects. Without this, a session
547        // that was just previewed in the orchestrator picker
548        // (`render_session_preview_into_rect` resizes PTYs to the
549        // embed rect — typically ~half the terminal's height) keeps
550        // drawing at that smaller size after the dive, leaving the
551        // bottom of the dive view blank until something else triggers
552        // a resize. Same applies for the inverse: dive away while a
553        // session has a small split, dive back when the window is
554        // bigger — the terminal needs the new dimensions. Route through
555        // the funnel so the dive-target window also picks up the current
556        // editor-global dock width (its `dock_cols` cache may be stale).
557        self.relayout();
558    }
559
560    /// Switch the active window and play a directional wipe over the
561    /// editor content as the incoming window appears. The editor
562    /// content geometry is layout-driven (identical for any session),
563    /// so the outgoing window's last content rect is the right area to
564    /// animate. `capture_before_all` snapshots the previous frame (the
565    /// outgoing window) and `SlideIn` slides the new content in over it.
566    pub fn set_active_window_animated(&mut self, id: WindowId, from_edge: &str) {
567        let animate = self.active_window != id
568            && self.windows.contains_key(&id)
569            && self.config().editor.animations;
570        // Wipe the ENTIRE window — menu bar, explorer, tabs, splits, and
571        // status bar — i.e. everything to the right of the dock. That's
572        // the chrome area from the dock split, not just the buffer's
573        // content rect. The dock column itself stays put.
574        let full = ratatui::layout::Rect {
575            x: 0,
576            y: 0,
577            width: self.terminal_width,
578            height: self.terminal_height,
579        };
580        let (_dock, area) = self.compute_dock_split(full);
581        self.set_active_window(id);
582        if !animate {
583            return;
584        }
585        if area.width == 0 || area.height == 0 {
586            return;
587        }
588        use crate::view::animation::{AnimationKind, Edge};
589        let from = match from_edge {
590            "top" => Edge::Top,
591            "bottom" => Edge::Bottom,
592            "left" => Edge::Left,
593            "right" => Edge::Right,
594            _ => Edge::Bottom,
595        };
596        self.active_window_mut().animations.start(
597            area,
598            AnimationKind::SlideIn {
599                from,
600                duration: std::time::Duration::from_millis(180),
601                delay: std::time::Duration::ZERO,
602            },
603        );
604    }
605
606    /// Cycle to the next open window in the workspace.
607    ///
608    /// Windows are ordered by their numeric `WindowId` (which is
609    /// monotonically assigned by `create_window_at`), so "next"
610    /// reads in creation order with wrap-around. No-op when only
611    /// one window is open (issue #2031).
612    pub fn next_window(&mut self) {
613        self.cycle_active_window(1);
614    }
615
616    /// Cycle to the previous open window. See [`Self::next_window`]
617    /// for ordering.
618    pub fn prev_window(&mut self) {
619        self.cycle_active_window(-1);
620    }
621
622    /// Step `delta` positions through the open windows (positive =
623    /// forward, negative = backward), wrapping around at the ends.
624    /// Centralises the cycle logic shared by `next_window` and
625    /// `prev_window` so both directions stay in sync if the
626    /// underlying ordering changes (e.g. user-controlled reorder).
627    fn cycle_active_window(&mut self, delta: isize) {
628        // A plugin (the orchestrator dock) may constrain cycling to a
629        // specific ordered subset — the windows currently visible in its
630        // session list — so Next/Prev Window walks exactly that list rather
631        // than every open window. Ids no longer open are dropped, preserving
632        // the given order. An empty result (or no override) falls back to the
633        // default: every window, ordered by id.
634        let override_ids: Option<Vec<WindowId>> = self
635            .window_cycle_order
636            .as_ref()
637            .map(|order| {
638                order
639                    .iter()
640                    .copied()
641                    .filter(|id| self.windows.contains_key(id))
642                    .collect::<Vec<_>>()
643            })
644            .filter(|kept| !kept.is_empty());
645        let ids: Vec<WindowId> = match override_ids {
646            Some(kept) => kept,
647            None => {
648                let mut all: Vec<WindowId> = self.windows.keys().copied().collect();
649                all.sort_by_key(|id| id.0);
650                all
651            }
652        };
653        if ids.len() <= 1 {
654            return;
655        }
656        let current_pos = match ids.iter().position(|id| *id == self.active_window) {
657            Some(pos) => pos as isize,
658            None => 0,
659        };
660        let len = ids.len() as isize;
661        let next_pos = (((current_pos + delta) % len) + len) % len;
662        let next_id = ids[next_pos as usize];
663        self.set_active_window(next_id);
664    }
665
666    /// Build a fresh seed buffer + split layout for `id` if that
667    /// window is missing either a split tree or any buffer to back
668    /// it. Returns `None` when the window is unknown or already
669    /// populated. The caller is responsible for installing the
670    /// returned tuple into the window's fields.
671    ///
672    /// Both branches (no splits, or splits but empty buffer map)
673    /// are pathological: render walks the active buffer and would
674    /// panic at `expect("active buffer must be present")` when the
675    /// split manager points at a buffer id that isn't in
676    /// `window.buffers`.
677    ///
678    /// Factored out of `set_active_window` so other call sites that
679    /// need to populate an inert window shell can share the same
680    /// seed-construction logic.
681    pub(crate) fn build_fresh_layout_if_needed(
682        &mut self,
683        id: WindowId,
684    ) -> Option<(
685        fresh_core::BufferId,
686        crate::state::EditorState,
687        crate::app::types::BufferMetadata,
688        crate::model::event::EventLog,
689        SplitManager,
690        HashMap<crate::model::event::LeafId, SplitViewState>,
691    )> {
692        if !self
693            .windows
694            .get(&id)
695            .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
696        {
697            return None;
698        }
699        let buf = self.alloc_buffer_id();
700        let mut state = crate::state::EditorState::new(
701            self.terminal_width,
702            self.terminal_height,
703            self.config.editor.large_file_threshold_bytes as usize,
704            std::sync::Arc::clone(&self.authority().filesystem),
705        );
706        state
707            .margins
708            .configure_for_line_numbers(self.config.editor.line_numbers);
709        state
710            .buffer
711            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
712        let metadata = crate::app::types::BufferMetadata::new();
713        let event_log = crate::model::event::EventLog::new();
714        let manager = SplitManager::new(buf);
715        let active_leaf = manager.active_split();
716        let mut view_states = HashMap::new();
717        view_states.insert(
718            active_leaf,
719            SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
720        );
721        Some((buf, state, metadata, event_log, manager, view_states))
722    }
723
724    /// Eagerly initialise an inactive session's per-session
725    /// state without diving. Useful for plugins (Orchestrator) that
726    /// want to pay the warm-up cost (file-tree walk, ignore
727    /// matcher, etc.) ahead of the user's first dive.
728    ///
729    /// In the current build this is a placeholder — file
730    /// explorer rebuilds and LSP boot still happen on first dive.
731    /// The API exists so callers don't have to be rewritten when
732    /// eager warm-up wires up later.
733    pub fn prewarm_window(&mut self, id: WindowId) {
734        if id == self.active_window {
735            return;
736        }
737        if !self.windows.contains_key(&id) {
738            tracing::warn!("prewarm_window: unknown session id {id}");
739        }
740        // Placeholder for eager warm-up of file_explorer / LSP.
741    }
742
743    /// Remove a buffer from whichever window holds it. Returns the
744    /// removed `EditorState` if the buffer was found. Step 0c: each
745    /// buffer lives in exactly one window, so this is at most one
746    /// successful removal.
747    pub(crate) fn detach_buffer_from_all_windows(
748        &mut self,
749        buffer_id: fresh_core::BufferId,
750    ) -> Option<crate::state::EditorState> {
751        for w in self.windows.values_mut() {
752            if let Some(state) = w.buffers.remove(&buffer_id) {
753                return Some(state);
754            }
755        }
756        None
757    }
758
759    /// Close a session and drop its `Session` entry. Refuses to
760    /// close the currently active session — the caller must switch
761    /// to a different session first. Refuses to close the *last*
762    /// remaining window — the editor must always host at least one.
763    ///
764    /// There is no special "base" window any more: id 1 is just the
765    /// window the editor launched into, closable like any other once
766    /// another window exists. The real invariant is "≥1 window", not
767    /// "id 1 lives forever".
768    ///
769    /// Returns `true` on success, `false` on rejection.
770    pub fn close_window(&mut self, id: WindowId) -> bool {
771        if self.windows.len() <= 1 {
772            tracing::warn!("close_window: refusing to close the last remaining window (id {id})");
773            return false;
774        }
775        if id == self.active_window {
776            tracing::warn!(
777                "close_window: refusing to close the active session (id {id}); \
778                 switch first via setActiveWindow"
779            );
780            return false;
781        }
782        if self.windows.remove(&id).is_none() {
783            tracing::warn!("close_window: unknown session id {id}");
784            return false;
785        }
786        // Tear down a born-attached remote session's connection (carrier +
787        // reconnect/heartbeat + runtime) when its window closes. No-op for
788        // local windows, which never have an entry.
789        if self.session_keepalives.remove(&id).is_some() {
790            tracing::info!("close_window: dropped remote session keepalive for window {id}");
791        }
792
793        self.plugin_manager
794            .read()
795            .unwrap()
796            .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
797
798        true
799    }
800
801    /// Born-attached remote session: create a **new window** whose authority is
802    /// the already-connected remote backend (Kubernetes / SSH / …), seed its
803    /// terminal *inside* that backend, and park the connection `keepalive`
804    /// keyed by the window so it outlives editor rebuilds and is torn down on
805    /// close.
806    ///
807    /// Unlike the global `install_authority_with_keepalive` restart, existing
808    /// windows are left untouched — the remote session coexists with them, and
809    /// `set_active_window` (Gap A) retargets the active authority when the user
810    /// switches. The new window is born under `authority` because it is passed
811    /// straight to `create_window_with_terminal` as that window's backend, so
812    /// its filesystem, LSP spawner, and terminal wrapper all act in the backend
813    /// from birth (there are no stale local handles to invalidate — the caveat
814    /// that gates hot-swapping an *existing* window's authority doesn't apply
815    /// here). `create_window_with_terminal` adopts the new window's authority
816    /// into the editor-wide caches before returning.
817    pub(crate) fn create_remote_session_window(
818        &mut self,
819        authority: crate::services::authority::Authority,
820        keepalive: Box<dyn std::any::Any + Send>,
821        root: PathBuf,
822        label: String,
823        command: Option<Vec<String>>,
824        spec: crate::services::authority::SessionAuthoritySpec,
825    ) -> Result<WindowId, String> {
826        match self.create_window_with_terminal(
827            root.clone(),
828            label,
829            Some(root),
830            command,
831            None,
832            authority,
833            None,
834        ) {
835            Ok((window_id, _terminal, _buffer)) => {
836                self.session_keepalives.insert(window_id, keepalive);
837                // Persist how to reconnect this backend on the new session so
838                // a restart / relaunch can bring it back rather than degrade
839                // it to local.
840                if let Some(w) = self.windows.get_mut(&window_id) {
841                    w.authority_spec = spec;
842                }
843                Ok(window_id)
844            }
845            Err(e) => {
846                // The connect succeeded but the window couldn't be seeded
847                // (e.g. the backend has no python3 / the pod died):
848                // `create_window_with_terminal` already rolled the active
849                // pointer back to the previous window and left the
850                // editor-wide authority untouched (it never installed the
851                // remote one), so just drop the keepalive (tears down the
852                // carrier).
853                drop(keepalive);
854                Err(e)
855            }
856        }
857    }
858}