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