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 // A new window for `root` is its own local session with its **own**
98 // per-session trust scoped to that root — not a clone of the active
99 // session's authority/trust (which would leak a trust decision across
100 // projects). Its `fs_manager` rides the same (host) filesystem.
101 let local_authority = self.local_session_authority(&root);
102 self.create_window_with_authority(root, label, local_authority)
103 }
104
105 /// Create a new window rooted at `root` under an explicit `authority`,
106 /// seeded with an empty scratch buffer + minimal split layout (so it is
107 /// renderable immediately) and announced via `window_created`.
108 ///
109 /// Unlike [`Self::create_window_at`] this does **not** dedup by root or
110 /// mint a fresh local authority — the caller supplies the backend. That
111 /// lets Switch Project (`change_working_dir`) carry a remote window's
112 /// already-connected authority onto the new project so the new root opens
113 /// on the same container / SSH host, while local callers pass a fresh
114 /// [`Self::local_session_authority`].
115 pub(crate) fn create_window_with_authority(
116 &mut self,
117 root: PathBuf,
118 label: String,
119 authority: crate::services::authority::Authority,
120 ) -> WindowId {
121 let id = WindowId(self.next_window_id);
122 self.next_window_id += 1;
123
124 let mut resources = self.window_resources();
125 resources.fs_manager = std::sync::Arc::new(crate::services::fs::FsManager::new(
126 std::sync::Arc::clone(&authority.filesystem),
127 ));
128 let mut session = Window::new(id, label, root.clone(), authority, resources);
129 session.terminal_width = self.terminal_width;
130 session.terminal_height = self.terminal_height;
131 let resolved_label = session.label.clone();
132 self.windows.insert(id, session);
133
134 // Same seed shape that `set_active_window` builds on
135 // first dive — installed eagerly so the window is
136 // immediately renderable from any code path that walks
137 // the windows map (preview rendering, embedded session
138 // panes, etc.).
139 if let Some((buf, state, metadata, event_log, mgr, vs)) =
140 self.build_fresh_layout_if_needed(id)
141 {
142 if let Some(s) = self.windows.get_mut(&id) {
143 s.buffers.set_splits((mgr, vs));
144 s.buffers.insert(buf, state);
145 s.buffer_metadata.insert(buf, metadata);
146 s.event_logs.insert(buf, event_log);
147 }
148 }
149
150 self.plugin_manager.read().unwrap().run_hook(
151 "window_created",
152 HookArgs::WindowCreated {
153 id: id.0,
154 label: resolved_label,
155 root: root.to_string_lossy().into_owned(),
156 },
157 );
158
159 id
160 }
161
162 /// A fresh per-session execution scope (trust + env) for `root` — the one
163 /// blessed factory. Each call mints handles owned by exactly one session,
164 /// so a trust decision or env activation in one window can never leak into
165 /// another. Every per-session window construction goes through this.
166 pub(crate) fn session_scope_for(
167 &self,
168 root: &std::path::Path,
169 ) -> crate::services::authority::SessionScope {
170 crate::services::authority::SessionScope::for_root(
171 root,
172 &self.dir_context.project_state_dir(root),
173 )
174 }
175
176 /// A fresh local authority for a brand-new session rooted at `root`, with
177 /// its **own** per-session trust + env (not clones of the active
178 /// session's) and a host filesystem. The canonical backend for the
179 /// Orchestrator's "New Session (Local)" flow: callers pass this to
180 /// [`Self::create_window_with_terminal`] so a new session for a different
181 /// project is born under its own local backend, trust, and env.
182 pub fn local_session_authority(
183 &self,
184 root: &std::path::Path,
185 ) -> crate::services::authority::Authority {
186 crate::services::authority::Authority::local_scoped(self.session_scope_for(root))
187 }
188
189 /// Atomic "create a new window seeded with an agent terminal"
190 /// entry point. Used by Orchestrator's new-session flow.
191 ///
192 /// Unlike `create_window_at`, this path deliberately does NOT
193 /// seed an empty `[No Name]` buffer up front — the terminal
194 /// becomes the window's seed via `create_plugin_terminal`'s
195 /// no-active-split branch, so the new window is born with a
196 /// single tab (the terminal) instead of `[No Name] | <agent>`.
197 ///
198 /// The eager-seed invariant `create_window_at` upholds
199 /// ("window is renderable immediately after returning") still
200 /// holds here: the call to `create_plugin_terminal` runs
201 /// synchronously on the same thread before this function
202 /// yields, installing the terminal-rooted split layout before
203 /// any other code can observe the window. The `window_created`
204 /// hook is intentionally fired *after* the terminal is wired
205 /// up so plugin handlers see the new window in its final
206 /// shape, not the half-built intermediate state.
207 ///
208 /// `root` must be absolute; the plugin-command dispatcher
209 /// validates this before reaching here.
210 ///
211 /// `authority` is the backend the new session is born under — passed
212 /// explicitly so this primitive never guesses. The Orchestrator's "New
213 /// Session (Local)" flow hands it [`Self::local_session_authority`] (a
214 /// fresh local backend sharing the editor's trust + env handles), so a
215 /// new session for a *different* project does not inherit the active
216 /// window's container/SSH/k8s backend just because that window was
217 /// focused when "+ New" was clicked. The born-attached remote-session
218 /// path (`create_remote_session_window`) passes its already-connected
219 /// backend so the new window's filesystem, LSP spawner, and terminal all
220 /// act remotely from birth.
221 ///
222 /// The editor-wide authority cache is re-pointed at the new active
223 /// window via [`Self::adopt_active_window_authority`] before returning,
224 /// so the status bar, quick-open, and the 100+ `self.authority` call
225 /// sites reflect the session the user just landed on rather than the one
226 /// they left.
227 ///
228 /// `resume` is the agent-resume argv to re-run instead of `command` if
229 /// this session is restored (Orchestrator agent-resume).
230 #[allow(clippy::too_many_arguments)]
231 pub fn create_window_with_terminal(
232 &mut self,
233 root: PathBuf,
234 label: String,
235 cwd: Option<PathBuf>,
236 command: Option<Vec<String>>,
237 title: Option<String>,
238 window_authority: crate::services::authority::Authority,
239 resume: Option<Vec<String>>,
240 ) -> Result<(WindowId, fresh_core::TerminalId, fresh_core::BufferId), String> {
241 let id = WindowId(self.next_window_id);
242 self.next_window_id += 1;
243
244 // The backend the editor was acting through before this new
245 // session — captured so `adopt_active_window_authority` can tell
246 // whether the active authority actually changed and skip the
247 // hook/snapshot churn when it didn't.
248 let previous_authority_label = self.authority().display_label.clone();
249
250 let mut resources = self.window_resources();
251 // Re-derive the window's `fs_manager` from *its* backend's filesystem
252 // so the file explorer rides this session's backend, then build the
253 // window owning `window_authority` outright.
254 resources.fs_manager = std::sync::Arc::new(crate::services::fs::FsManager::new(
255 std::sync::Arc::clone(&window_authority.filesystem),
256 ));
257 let mut session = Window::new(id, label, root.clone(), window_authority, resources);
258 session.terminal_width = self.terminal_width;
259 session.terminal_height = self.terminal_height;
260 let resolved_label = session.label.clone();
261 self.windows.insert(id, session);
262
263 // Dive into the new window before spawning the terminal
264 // so `Window::create_plugin_terminal` operates on a window
265 // with `splits.is_none()` — that's the "no active_split"
266 // branch which seeds the layout rooted at the terminal
267 // buffer. We bypass `set_active_window`'s
268 // `build_fresh_layout_if_needed` call (which would install
269 // a `[No Name]` seed) by writing the active-window pointer
270 // directly.
271 let previous_id = self.active_window;
272 self.active_window = id;
273
274 // Run the workspace-trust decision for the project this session opens,
275 // exactly as the CLI / session-server startup paths do — the
276 // orchestrator's "New Session" path bypasses those, so without this a
277 // never-decided project opened through the dock stays Restricted and
278 // its env manager (venv / direnv / mise) never activates, leaving the
279 // terminal below on the *system* toolchain even though a direct
280 // `fresh <dir>` would have auto-trusted and activated it. `authority()`
281 // already follows `active_window` (set just above), so this scopes to
282 // the new window's trust + root. Local only: remote (born-attached
283 // SSH/k8s) sessions manage trust through their own connect flow and
284 // their markers don't live on the host filesystem.
285 if self
286 .authority()
287 .filesystem
288 .remote_connection_info()
289 .is_none()
290 {
291 self.maybe_prompt_workspace_trust();
292 }
293
294 // The argv to re-run if this session is restored. `None` (plain
295 // shell) is recorded as an empty vec: a present entry — even empty —
296 // marks this as a restorable *session* terminal (re-spawn it on
297 // restore), distinct from a throwaway ephemeral build/exec shell.
298 let restore_command = command.clone().unwrap_or_default();
299 let spawn_result = {
300 let target = self
301 .windows
302 .get_mut(&id)
303 .expect("just-inserted window must be present");
304 target.create_plugin_terminal(
305 cwd.or_else(|| Some(root.clone())),
306 None, // no split direction — let the no-layout branch seed
307 None,
308 true, // focus — newly spawned terminal is the seed
309 false, // ephemeral by default; orchestrator owns persistence
310 command,
311 title.filter(|t| !t.is_empty()),
312 )
313 };
314
315 let (terminal_id, buffer_id, _split_id) = match spawn_result {
316 Ok(triple) => triple,
317 Err(e) => {
318 // Roll back: tear down the half-built window and
319 // restore the previous active pointer so the user
320 // isn't stranded on an empty window when the PTY
321 // spawn fails (missing binary, permission denied,
322 // out of PTYs, ...).
323 self.windows.remove(&id);
324 self.active_window = previous_id;
325 return Err(e);
326 }
327 };
328
329 // Mark the freshly-spawned agent terminal restorable so workspace
330 // capture persists it (with its command) and a later launch
331 // re-runs it, instead of the session coming back as a blank pane.
332 // An explicit `resume` argv (agent-resume) supersedes the launch
333 // command on restore — see `restore_terminal_from_workspace`.
334 if let Some(target) = self.windows.get_mut(&id) {
335 target
336 .terminal_commands
337 .insert(terminal_id, restore_command);
338 if let Some(resume_argv) = resume.filter(|a| !a.is_empty()) {
339 target
340 .terminal_resume_commands
341 .insert(terminal_id, resume_argv);
342 }
343 }
344
345 // The switch has now committed (the spawn succeeded and the active
346 // pointer stays on the new window). This path wrote `active_window`
347 // directly above, bypassing `set_active_window` — so mirror its
348 // guard here, or a panel-scoped mode set on the window we switched
349 // away from (e.g. the New-Session form's `orchestrator-new-form`,
350 // still mounted during a born-attached SSH/K8s attach) is left
351 // stranded and silently swallows all of that window's buffer input.
352 // See #2237 / #2234 item 4.
353 self.clear_panel_scoped_mode_on_switch_away(previous_id);
354
355 // Adopt the new active window's authority into the editor-wide
356 // caches (`self.authority`, quick-open, the `authority_changed`
357 // hook). This path writes `active_window` directly and bypasses
358 // `set_active_window`, so without this the status bar + the 100+
359 // `self.authority` call sites keep reporting the *previous*
360 // window's backend — e.g. a new local session created from a
361 // devcontainer window would still show `Container:…` and route
362 // file ops through the container. The window's own
363 // `resources.authority` was already set above (local by default,
364 // or the explicit remote backend for born-attached sessions).
365 self.adopt_active_window_authority(&previous_authority_label);
366
367 // Register the leader pid with the new window's
368 // process_groups so window-level signal operations reach
369 // the spawned group. Mirrors `create_plugin_terminal`'s
370 // registration in the active-target path of
371 // `handle_create_terminal`, but kept here because we
372 // bypass that dispatcher.
373 if let Some(pid) = self
374 .windows
375 .get(&id)
376 .and_then(|w| w.terminal_manager.get(terminal_id))
377 .and_then(|h| h.pid())
378 {
379 let pg_label = format!("terminal #{}", terminal_id.0);
380 if let Some(win) = self.windows.get_mut(&id) {
381 win.process_groups.register(pid, pg_label);
382 }
383 }
384
385 // Size the newly-created window's PTYs (mirrors
386 // `set_active_window`'s post-dive resize so the seeded terminal
387 // renders into the right cell rect on its first frame). Route
388 // through the funnel rather than `win.resize_visible_terminals()`
389 // directly: a brand-new window's `dock_cols` cache is still 0, and
390 // `relayout` pushes the current editor-global dock width into every
391 // window before sizing, so the seeded terminal accounts for a dock
392 // that's already showing.
393 self.relayout();
394
395 // Plugin lifecycle: fire `window_created` first, then
396 // `active_window_changed`. Order mirrors the
397 // `create_window_at` + `set_active_window` sequence the
398 // orchestrator previously chained — plugin handlers that
399 // care about either event see the same payload order.
400 self.plugin_manager.read().unwrap().run_hook(
401 "window_created",
402 HookArgs::WindowCreated {
403 id: id.0,
404 label: resolved_label,
405 root: root.to_string_lossy().into_owned(),
406 },
407 );
408 if previous_id != id {
409 self.plugin_manager.read().unwrap().run_hook(
410 "active_window_changed",
411 HookArgs::ActiveWindowChanged {
412 previous_id: Some(previous_id.0),
413 active_id: id.0,
414 },
415 );
416 }
417 #[cfg(feature = "plugins")]
418 self.update_plugin_state_snapshot();
419 #[cfg(feature = "plugins")]
420 self.plugin_manager.read().unwrap().run_hook(
421 "buffer_activated",
422 crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
423 );
424
425 Ok((id, terminal_id, buffer_id))
426 }
427
428 /// Clear a floating-panel-scoped editor mode on the window we are
429 /// switching *away* from.
430 ///
431 /// A plugin-defined editor mode (`editor.setEditorMode`) tied to a mounted
432 /// floating widget panel — the Orchestrator picker (`orchestrator-open`) or
433 /// new-session form (`orchestrator-new-form`) — is transient UI state that
434 /// belongs to the *panel*, not to the window it was opened over.
435 /// `setEditorMode` writes to whatever window is active when the plugin
436 /// calls it, so a plugin that switches the active window while its panel is
437 /// still mounted (the orchestrator "dive": `setActiveWindow(target)` first,
438 /// then `closeOpenDialog()` / `closeForm()` which runs
439 /// `setEditorMode(null)`) lands the clear on the *incoming* window and
440 /// leaves the *outgoing* one stuck in the panel's mode. That stuck mode
441 /// stays masked while the window sits in terminal mode, then silently
442 /// swallows every printable key the moment the user leaves terminal mode
443 /// (e.g. opens a file via quick-open) — the buffer ignores all keyboard
444 /// input until the user switches sessions.
445 ///
446 /// Both window-switch paths must call this before moving the active
447 /// pointer: the ordinary `set_active_window` dive *and* the born-attached
448 /// remote session creation (`create_window_with_terminal`), which writes
449 /// the active pointer directly and so never reaches `set_active_window`'s
450 /// own guard. See #2237 / #2234 item 4.
451 ///
452 /// vi-mode and other persistent per-window modes are unaffected: they never
453 /// have a floating panel mounted during a window switch.
454 fn clear_panel_scoped_mode_on_switch_away(&mut self, previous_id: WindowId) {
455 if self.floating_widget_panel.is_some() {
456 if let Some(win) = self.windows.get_mut(&previous_id) {
457 win.editor_mode = None;
458 }
459 }
460 }
461
462 /// Switch the active window to `id`.
463 ///
464 /// Pointer write: every per-window field
465 /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
466 /// already lives on `Window`, so flipping `active_window` is the
467 /// whole switch. Diving into a never-activated window seeds it
468 /// with a fresh empty buffer + SplitManager so the renderer
469 /// finds a populated `splits` field.
470 ///
471 /// No-op when `id` is already active. Logs and returns when
472 /// `id` is unknown — the design treats unknown ids as a plugin
473 /// bug (caller verifies with `listWindows`), not a recoverable
474 /// error worth surfacing through the channel.
475 pub fn set_active_window(&mut self, id: WindowId) {
476 if self.active_window == id {
477 return;
478 }
479 if !self.windows.contains_key(&id) {
480 tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
481 return;
482 }
483
484 let previous_id = self.active_window;
485 // Capture the outgoing backend label so we can tell, after the
486 // switch, whether the active *authority* actually changed (most
487 // window switches are between same-authority local sessions, where
488 // it doesn't). Only then do we re-point editor-wide caches + fire
489 // the `authority_changed` hook.
490 let previous_authority_label = self.authority().display_label.clone();
491
492 // Clear any panel-scoped editor mode on the window we're leaving so
493 // it can never outlive the switch (see
494 // `clear_panel_scoped_mode_on_switch_away`).
495 self.clear_panel_scoped_mode_on_switch_away(previous_id);
496
497 // Lazy materialization: if this window's saved workspace hasn't
498 // been restored yet, restore it now (before seeding) so the
499 // dive lands on real content rather than an empty buffer.
500 self.materialize_window(id);
501
502 // For a never-activated incoming window, allocate a fresh
503 // seed buffer + SplitManager rooted at it. The state is
504 // installed into the incoming window's `buffers` map after
505 // the active pointer moves. After a successful materialize the
506 // window already has splits, so this is a no-op.
507 let fresh_layout = self.build_fresh_layout_if_needed(id);
508
509 // Pointer write — that's the whole switch. `working_dir()`
510 // derives from the active window's root, so moving the pointer
511 // is all it takes (no separate working_dir to sync).
512 self.active_window = id;
513
514 // For a never-activated incoming window, install the freshly
515 // built layout into the window's `splits` field and attach
516 // the seed buffer.
517 if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
518 if let Some(s) = self.windows.get_mut(&id) {
519 s.buffers.set_splits((mgr, vs));
520 s.buffers.insert(buf, state);
521 s.buffer_metadata.insert(buf, metadata);
522 s.event_logs.insert(buf, event_log);
523 }
524 }
525
526 // Authority follows the active window. Each `Window` owns its
527 // `resources.authority`; the editor-wide `self.authority` cache (read
528 // by the 100+ filesystem/spawn/terminal call sites) must now reflect
529 // the window we just switched to, or a per-session remote/cloud
530 // backend would silently keep acting through the previous window's
531 // authority. This is the switch-time counterpart to
532 // `set_session_authority` (which mirrors on swap of the *active*
533 // window) — see `AUTHORITY_DESIGN.md` §"Evolution: per-session
534 // authority". Cheap for the common case: same-authority local windows
535 // share `Arc`s and the label is unchanged, so the hook below is
536 // skipped.
537 self.adopt_active_window_authority(&previous_authority_label);
538
539 // If we just switched to a remote session that came back from disk
540 // dormant (backend spec known, live authority still the local
541 // placeholder), start reconnecting its backend now — the per-window
542 // activation the per-session design calls for. SSH/k8s reconnect from
543 // core; the agent terminals re-run in the live backend once it lands.
544 #[cfg(feature = "plugins")]
545 self.reconnect_dormant_session_if_needed(id);
546
547 // Refresh the plugin state snapshot so `getCwd()` (and every
548 // other snapshot field) reflects the window we just switched
549 // to *before* the `active_window_changed` hook runs. Without
550 // this, plugins that read `editor.getCwd()` — Live Grep, file
551 // finders, etc. — keep targeting the previous window's project
552 // after a dive, surfacing the wrong project's files.
553 #[cfg(feature = "plugins")]
554 self.update_plugin_state_snapshot();
555
556 self.plugin_manager.read().unwrap().run_hook(
557 "active_window_changed",
558 HookArgs::ActiveWindowChanged {
559 previous_id: Some(previous_id.0),
560 active_id: id.0,
561 },
562 );
563
564 // Reflow the newly-active window's visible terminal PTYs to
565 // match their dive-view split rects. Without this, a session
566 // that was just previewed in the orchestrator picker
567 // (`render_session_preview_into_rect` resizes PTYs to the
568 // embed rect — typically ~half the terminal's height) keeps
569 // drawing at that smaller size after the dive, leaving the
570 // bottom of the dive view blank until something else triggers
571 // a resize. Same applies for the inverse: dive away while a
572 // session has a small split, dive back when the window is
573 // bigger — the terminal needs the new dimensions. Route through
574 // the funnel so the dive-target window also picks up the current
575 // editor-global dock width (its `dock_cols` cache may be stale).
576 self.relayout();
577 }
578
579 /// Switch the active window and play a directional wipe over the
580 /// editor content as the incoming window appears. The editor
581 /// content geometry is layout-driven (identical for any session),
582 /// so the outgoing window's last content rect is the right area to
583 /// animate. `capture_before_all` snapshots the previous frame (the
584 /// outgoing window) and `SlideIn` slides the new content in over it.
585 pub fn set_active_window_animated(&mut self, id: WindowId, from_edge: &str) {
586 let animate = self.active_window != id
587 && self.windows.contains_key(&id)
588 && self.config().editor.animations;
589 // Wipe the ENTIRE window — menu bar, explorer, tabs, splits, and
590 // status bar — i.e. everything to the right of the dock. That's
591 // the chrome area from the dock split, not just the buffer's
592 // content rect. The dock column itself stays put.
593 let full = ratatui::layout::Rect {
594 x: 0,
595 y: 0,
596 width: self.terminal_width,
597 height: self.terminal_height,
598 };
599 let (_dock, area) = self.compute_dock_split(full);
600 self.set_active_window(id);
601 if !animate {
602 return;
603 }
604 if area.width == 0 || area.height == 0 {
605 return;
606 }
607 use crate::view::animation::{AnimationKind, Edge};
608 let from = match from_edge {
609 "top" => Edge::Top,
610 "bottom" => Edge::Bottom,
611 "left" => Edge::Left,
612 "right" => Edge::Right,
613 _ => Edge::Bottom,
614 };
615 self.active_window_mut().animations.start(
616 area,
617 AnimationKind::SlideIn {
618 from,
619 duration: std::time::Duration::from_millis(180),
620 delay: std::time::Duration::ZERO,
621 },
622 );
623 }
624
625 /// Cycle to the next open window in the workspace.
626 ///
627 /// Windows are ordered by their numeric `WindowId` (which is
628 /// monotonically assigned by `create_window_at`), so "next"
629 /// reads in creation order with wrap-around. No-op when only
630 /// one window is open (issue #2031).
631 pub fn next_window(&mut self) {
632 self.cycle_active_window(1);
633 }
634
635 /// Cycle to the previous open window. See [`Self::next_window`]
636 /// for ordering.
637 pub fn prev_window(&mut self) {
638 self.cycle_active_window(-1);
639 }
640
641 /// Step `delta` positions through the open windows (positive =
642 /// forward, negative = backward), wrapping around at the ends.
643 /// Centralises the cycle logic shared by `next_window` and
644 /// `prev_window` so both directions stay in sync if the
645 /// underlying ordering changes (e.g. user-controlled reorder).
646 fn cycle_active_window(&mut self, delta: isize) {
647 // A plugin (the orchestrator dock) may constrain cycling to a
648 // specific ordered subset — the windows currently visible in its
649 // session list — so Next/Prev Window walks exactly that list rather
650 // than every open window. Ids no longer open are dropped, preserving
651 // the given order. An empty result (or no override) falls back to the
652 // default: every window, ordered by id.
653 let override_ids: Option<Vec<WindowId>> = self
654 .window_cycle_order
655 .as_ref()
656 .map(|order| {
657 order
658 .iter()
659 .copied()
660 .filter(|id| self.windows.contains_key(id))
661 .collect::<Vec<_>>()
662 })
663 .filter(|kept| !kept.is_empty());
664 let ids: Vec<WindowId> = match override_ids {
665 Some(kept) => kept,
666 None => {
667 let mut all: Vec<WindowId> = self.windows.keys().copied().collect();
668 all.sort_by_key(|id| id.0);
669 all
670 }
671 };
672 if ids.len() <= 1 {
673 return;
674 }
675 let current_pos = match ids.iter().position(|id| *id == self.active_window) {
676 Some(pos) => pos as isize,
677 None => 0,
678 };
679 let len = ids.len() as isize;
680 let next_pos = (((current_pos + delta) % len) + len) % len;
681 let next_id = ids[next_pos as usize];
682 self.set_active_window(next_id);
683 }
684
685 /// Build a fresh seed buffer + split layout for `id` if that
686 /// window is missing either a split tree or any buffer to back
687 /// it. Returns `None` when the window is unknown or already
688 /// populated. The caller is responsible for installing the
689 /// returned tuple into the window's fields.
690 ///
691 /// Both branches (no splits, or splits but empty buffer map)
692 /// are pathological: render walks the active buffer and would
693 /// panic at `expect("active buffer must be present")` when the
694 /// split manager points at a buffer id that isn't in
695 /// `window.buffers`.
696 ///
697 /// Factored out of `set_active_window` so other call sites that
698 /// need to populate an inert window shell can share the same
699 /// seed-construction logic.
700 pub(crate) fn build_fresh_layout_if_needed(
701 &mut self,
702 id: WindowId,
703 ) -> Option<(
704 fresh_core::BufferId,
705 crate::state::EditorState,
706 crate::app::types::BufferMetadata,
707 crate::model::event::EventLog,
708 SplitManager,
709 HashMap<crate::model::event::LeafId, SplitViewState>,
710 )> {
711 if !self
712 .windows
713 .get(&id)
714 .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
715 {
716 return None;
717 }
718 let buf = self.alloc_buffer_id();
719 let mut state = crate::state::EditorState::new(
720 self.terminal_width,
721 self.terminal_height,
722 self.config.editor.large_file_threshold_bytes as usize,
723 std::sync::Arc::clone(&self.authority().filesystem),
724 );
725 state
726 .margins
727 .configure_for_line_numbers(self.config.editor.line_numbers);
728 state
729 .buffer
730 .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
731 let metadata = crate::app::types::BufferMetadata::new();
732 let event_log = crate::model::event::EventLog::new();
733 let manager = SplitManager::new(buf);
734 let active_leaf = manager.active_split();
735 let mut view_states = HashMap::new();
736 view_states.insert(
737 active_leaf,
738 SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
739 );
740 Some((buf, state, metadata, event_log, manager, view_states))
741 }
742
743 /// Eagerly initialise an inactive session's per-session
744 /// state without diving. Useful for plugins (Orchestrator) that
745 /// want to pay the warm-up cost (file-tree walk, ignore
746 /// matcher, etc.) ahead of the user's first dive.
747 ///
748 /// In the current build this is a placeholder — file
749 /// explorer rebuilds and LSP boot still happen on first dive.
750 /// The API exists so callers don't have to be rewritten when
751 /// eager warm-up wires up later.
752 pub fn prewarm_window(&mut self, id: WindowId) {
753 if id == self.active_window {
754 return;
755 }
756 if !self.windows.contains_key(&id) {
757 tracing::warn!("prewarm_window: unknown session id {id}");
758 }
759 // Placeholder for eager warm-up of file_explorer / LSP.
760 }
761
762 /// Remove a buffer from whichever window holds it. Returns the
763 /// removed `EditorState` if the buffer was found. Step 0c: each
764 /// buffer lives in exactly one window, so this is at most one
765 /// successful removal.
766 pub(crate) fn detach_buffer_from_all_windows(
767 &mut self,
768 buffer_id: fresh_core::BufferId,
769 ) -> Option<crate::state::EditorState> {
770 for w in self.windows.values_mut() {
771 if let Some(state) = w.buffers.remove(&buffer_id) {
772 return Some(state);
773 }
774 }
775 None
776 }
777
778 /// Close a session and drop its `Session` entry. Refuses to
779 /// close the currently active session — the caller must switch
780 /// to a different session first. Refuses to close the *last*
781 /// remaining window — the editor must always host at least one.
782 ///
783 /// There is no special "base" window any more: id 1 is just the
784 /// window the editor launched into, closable like any other once
785 /// another window exists. The real invariant is "≥1 window", not
786 /// "id 1 lives forever".
787 ///
788 /// Returns `true` on success, `false` on rejection.
789 pub fn close_window(&mut self, id: WindowId) -> bool {
790 // A dormant remote session has no `Window` and no live connection —
791 // closing it just drops its descriptor so it leaves the dock. (It can
792 // never be the active window, and isn't the "last window".)
793 if self.dormant_remote.remove(&id).is_some() {
794 self.plugin_manager
795 .read()
796 .unwrap()
797 .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
798 return true;
799 }
800 if self.windows.len() <= 1 {
801 tracing::warn!("close_window: refusing to close the last remaining window (id {id})");
802 return false;
803 }
804 if id == self.active_window {
805 tracing::warn!(
806 "close_window: refusing to close the active session (id {id}); \
807 switch first via setActiveWindow"
808 );
809 return false;
810 }
811 if self.windows.remove(&id).is_none() {
812 tracing::warn!("close_window: unknown session id {id}");
813 return false;
814 }
815 // Tear down a born-attached remote session's connection (carrier +
816 // reconnect/heartbeat + runtime) when its window closes. No-op for
817 // local windows, which never have an entry.
818 if self.session_keepalives.remove(&id).is_some() {
819 tracing::info!("close_window: dropped remote session keepalive for window {id}");
820 }
821
822 self.plugin_manager
823 .read()
824 .unwrap()
825 .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
826
827 true
828 }
829
830 /// Born-attached remote session: create a **new window** whose authority is
831 /// the already-connected remote backend (Kubernetes / SSH / …), seed its
832 /// terminal *inside* that backend, and park the connection `keepalive`
833 /// keyed by the window so it outlives editor rebuilds and is torn down on
834 /// close.
835 ///
836 /// Unlike the global `install_authority_with_keepalive` restart, existing
837 /// windows are left untouched — the remote session coexists with them, and
838 /// `set_active_window` (Gap A) retargets the active authority when the user
839 /// switches. The new window is born under `authority` because it is passed
840 /// straight to `create_window_with_terminal` as that window's backend, so
841 /// its filesystem, LSP spawner, and terminal wrapper all act in the backend
842 /// from birth (there are no stale local handles to invalidate — the caveat
843 /// that gates hot-swapping an *existing* window's authority doesn't apply
844 /// here). `create_window_with_terminal` adopts the new window's authority
845 /// into the editor-wide caches before returning.
846 pub(crate) fn create_remote_session_window(
847 &mut self,
848 authority: crate::services::authority::Authority,
849 keepalive: Box<dyn std::any::Any + Send>,
850 root: PathBuf,
851 label: String,
852 command: Option<Vec<String>>,
853 spec: crate::services::authority::SessionAuthoritySpec,
854 ) -> Result<WindowId, String> {
855 match self.create_window_with_terminal(
856 root.clone(),
857 label,
858 Some(root),
859 command,
860 None,
861 authority,
862 None,
863 ) {
864 Ok((window_id, _terminal, _buffer)) => {
865 self.session_keepalives.insert(window_id, keepalive);
866 // Persist how to reconnect this backend on the new session so
867 // a restart / relaunch can bring it back rather than degrade
868 // it to local.
869 if let Some(w) = self.windows.get_mut(&window_id) {
870 w.authority_spec = spec;
871 }
872 Ok(window_id)
873 }
874 Err(e) => {
875 // The connect succeeded but the window couldn't be seeded
876 // (e.g. the backend has no python3 / the pod died):
877 // `create_window_with_terminal` already rolled the active
878 // pointer back to the previous window and left the
879 // editor-wide authority untouched (it never installed the
880 // remote one), so just drop the keepalive (tears down the
881 // carrier).
882 drop(keepalive);
883 Err(e)
884 }
885 }
886 }
887
888 /// Begin bringing a **dormant remote** session online: connect its SSH/kube
889 /// backend, then — on success — promote it to a real `Window`
890 /// ([`Self::promote_dormant_remote`]). Used when the user dives into a
891 /// session that boot discovered but never connected: it has no `Window` yet,
892 /// only a `dormant_remote` descriptor (no authority). The active window is
893 /// left unchanged until the connection lands, so the editor never shows a
894 /// window without its real backend.
895 pub(crate) fn bring_dormant_remote_online(&mut self, id: WindowId) {
896 let Some(descriptor) = self.dormant_remote.get(&id) else {
897 return;
898 };
899 // Only remote-agent sessions are ever placed in `dormant_remote`.
900 let spec = match &descriptor.authority_spec {
901 crate::services::authority::SessionAuthoritySpec::RemoteAgent(s) => s.clone(),
902 _ => return,
903 };
904 let request_id = u64::MAX - id.0;
905 if self.remote_attach_inflight.contains(&request_id) {
906 return; // a connect for this session is already in flight
907 }
908 // `start_remote_connect` posts a "Connecting to …" status and, on
909 // success, emits `RemoteAttachReady` in `Reconnect { window_id: id }`
910 // mode — which `promote_dormant_remote` turns into the live window. The
911 // remote connect machinery is plugins-gated (dormant remote sessions are
912 // created through the orchestrator plugin); without it there is nothing
913 // to connect through, so diving into one is a no-op.
914 #[cfg(feature = "plugins")]
915 self.start_remote_connect(spec, Some(id), request_id);
916 #[cfg(not(feature = "plugins"))]
917 let _ = (spec, request_id);
918 }
919
920 /// Promote a dormant remote session to a live `Window`, **born with the
921 /// freshly-connected `authority`**. Its persisted workspace is restored
922 /// through that authority, so its terminals spawn on the remote backend —
923 /// never the local host. This is the *only* path that turns a
924 /// `dormant_remote` descriptor into a `Window`; there is deliberately no way
925 /// to build that window without the connected backend in hand, which is what
926 /// makes "a restored remote terminal running locally" unrepresentable.
927 pub(crate) fn promote_dormant_remote(
928 &mut self,
929 id: WindowId,
930 authority: crate::services::authority::Authority,
931 keepalive: Box<dyn std::any::Any + Send>,
932 ) {
933 let Some(descriptor) = self.dormant_remote.remove(&id) else {
934 // Raced with a close / a second connect — nothing to promote.
935 drop(authority);
936 drop(keepalive);
937 return;
938 };
939 let root = descriptor.root.clone();
940
941 // Resources rooted at this window's *own* backend filesystem (remote),
942 // so its file explorer / quick-open ride the session's backend.
943 let mut resources = self.window_resources();
944 resources.fs_manager = std::sync::Arc::new(crate::services::fs::FsManager::new(
945 std::sync::Arc::clone(&authority.filesystem),
946 ));
947
948 // Restore the persisted workspace through the connected authority (its
949 // terminals spawn over SSH/kube), or seed an empty layout when there is
950 // no saved workspace. Either constructor takes the authority by value —
951 // the window is born owning its real backend.
952 let workspace = if let Some(name) = self.session_name.clone() {
953 crate::workspace::Workspace::load_session(&name, &root)
954 .ok()
955 .flatten()
956 } else {
957 crate::workspace::Workspace::load(&root).ok().flatten()
958 };
959 let mut window = match workspace {
960 Some(ws) => crate::app::window::Window::from_workspace(
961 id,
962 descriptor.label.clone(),
963 root.clone(),
964 authority,
965 resources,
966 &ws,
967 ),
968 None => {
969 let mut w = crate::app::window::Window::new(
970 id,
971 descriptor.label.clone(),
972 root.clone(),
973 authority,
974 resources,
975 );
976 w.seed_initial_layout();
977 w
978 }
979 };
980 window.terminal_width = self.terminal_width;
981 window.terminal_height = self.terminal_height;
982 window.plugin_state = descriptor.plugin_state.clone();
983 window.authority_spec = descriptor.authority_spec.clone();
984 self.windows.insert(id, window);
985 self.session_keepalives.insert(id, keepalive);
986
987 // Activate through the normal switch path: nothing to materialize
988 // (already restored), no reconnect re-trigger (keepalive now parked),
989 // and it adopts the new authority into the editor caches, fires
990 // `active_window_changed`, and relayouts.
991 self.set_active_window(id);
992 self.set_status_message(format!("Connected: {}", descriptor.label));
993 }
994}