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}