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}