Skip to main content

fresh/app/
window_actions.rs

1//! Editor methods for window lifecycle (create, switch, close).
2//!
3//! Windows are introduced in
4//! `docs/internal/orchestrator-sessions-design.md`. After Step 0b each
5//! window owns its file tree, file mod-times, LSP set, panel-id
6//! map, and split layout outright. `set_active_window` is therefore
7//! a pointer write (plus seed-buffer allocation when diving into a
8//! never-activated window) — there are no warm-swap stashes left to
9//! shuffle. Plugins that listen for `active_window_changed` see the
10//! same hook sequence as before.
11
12use crate::app::window::Window;
13use crate::app::window_resources::WindowResources;
14use crate::services::plugins::hooks::HookArgs;
15use crate::view::split::{SplitManager, SplitViewState};
16use fresh_core::WindowId;
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20impl crate::app::Editor {
21    /// Snapshot the editor-global resources every new `Window` needs.
22    /// All fields are cheap clones (`Arc` increments or `Clone`-by-value
23    /// where the inner type already holds `Arc`s, like `Authority`).
24    /// Called by `create_window_at` and by the first-dive seed path in
25    /// `set_active_window`; also by `editor_init` for the base window.
26    pub(crate) fn window_resources(&self) -> WindowResources {
27        WindowResources {
28            config: std::sync::Arc::clone(&self.config),
29            grammar_registry: std::sync::Arc::clone(&self.grammar_registry),
30            theme_registry: std::sync::Arc::clone(&self.theme_registry),
31            theme_cache: std::sync::Arc::clone(&self.theme_cache),
32            keybindings: std::sync::Arc::clone(&self.keybindings),
33            command_registry: std::sync::Arc::clone(&self.command_registry),
34            // Derive the window's fs_manager from the *same* authority we hand
35            // it below, so directory listings (the file explorer) ride the
36            // window's filesystem — local or remote — instead of a stale,
37            // boot-time local one. A born-attached SSH/k8s window otherwise
38            // showed the local machine in the explorer while its terminal ran
39            // remote, because the cached fs_manager never tracked the authority.
40            // Default to a host fs_manager; callers that build a window with a
41            // non-local authority re-derive it from that authority's
42            // filesystem. The window's `authority` is set on the `Window`
43            // itself (not here in the `Clone`-fanned resources).
44            fs_manager: std::sync::Arc::new(crate::services::fs::FsManager::new(
45                std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
46            )),
47            local_filesystem: std::sync::Arc::clone(&self.local_filesystem),
48            buffer_id_alloc: self.buffer_id_alloc.clone(),
49            time_source: std::sync::Arc::clone(&self.time_source),
50            dir_context: self.dir_context.clone(),
51            tokio_runtime: self.tokio_runtime.clone(),
52            async_bridge: self.async_bridge.clone(),
53            plugin_manager: std::sync::Arc::clone(&self.plugin_manager),
54            theme: std::sync::Arc::clone(&self.theme),
55            event_broadcaster: self.event_broadcaster.clone(),
56            recovery_service: std::sync::Arc::clone(&self.recovery_service),
57        }
58    }
59
60    /// Allocate a session id, insert a new `Session`, fire
61    /// `session_created`. Does not switch active.
62    ///
63    /// Caller is responsible for ensuring `root` is absolute. The
64    /// `PluginCommand::CreateWindow` dispatcher rejects relative
65    /// paths before reaching here.
66    ///
67    /// Find an existing window whose root resolves to the same
68    /// canonical directory, if any. Backs the one-session-per-dir
69    /// invariant: opening a directory that already has a window
70    /// reuses it rather than creating a duplicate.
71    pub(crate) fn find_window_by_root(&self, root: &std::path::Path) -> Option<WindowId> {
72        let key = crate::app::orchestrator_persistence::canonical_key(root);
73        self.windows
74            .iter()
75            .find(|(_, w)| crate::app::orchestrator_persistence::canonical_key(&w.root) == key)
76            .map(|(id, _)| *id)
77    }
78
79    /// Open the window for `root`, creating it if absent. Enforces
80    /// one-session-per-directory: if a window already exists at the
81    /// same canonical root it is returned as-is and `label` is
82    /// ignored (the existing window keeps its label) — no duplicate
83    /// is created.
84    ///
85    /// Seeds a freshly created window with an empty scratch buffer +
86    /// a minimal split layout up front (same shape as the first-dive
87    /// seed path), so the window is renderable immediately. Without
88    /// this, never-dived windows have `splits == None` and any
89    /// cross-window render (e.g. the Orchestrator preview pane's
90    /// `WindowEmbed`) draws blank.
91    pub fn create_window_at(&mut self, root: PathBuf, label: String) -> WindowId {
92        // One session per directory: reuse an existing window at this
93        // root instead of spawning a colliding duplicate.
94        if let Some(existing) = self.find_window_by_root(&root) {
95            return existing;
96        }
97        // 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}