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            fs_manager: std::sync::Arc::clone(&self.fs_manager),
35            local_filesystem: std::sync::Arc::clone(&self.local_filesystem),
36            buffer_id_alloc: self.buffer_id_alloc.clone(),
37            authority: self.authority.clone(),
38            time_source: std::sync::Arc::clone(&self.time_source),
39            dir_context: self.dir_context.clone(),
40            tokio_runtime: self.tokio_runtime.clone(),
41            async_bridge: self.async_bridge.clone(),
42            plugin_manager: std::sync::Arc::clone(&self.plugin_manager),
43            theme: std::sync::Arc::clone(&self.theme),
44            event_broadcaster: self.event_broadcaster.clone(),
45        }
46    }
47
48    /// Allocate a session id, insert a new `Session`, fire
49    /// `session_created`. Does not switch active.
50    ///
51    /// Caller is responsible for ensuring `root` is absolute. The
52    /// `PluginCommand::CreateWindow` dispatcher rejects relative
53    /// paths before reaching here.
54    ///
55    /// Seeds the new window with an empty scratch buffer + a
56    /// minimal split layout up front (same shape as the
57    /// first-dive seed path), so the window is renderable
58    /// immediately. Without this, never-dived windows have
59    /// `splits == None` and any cross-window render (e.g. the
60    /// Orchestrator preview pane's `WindowEmbed`) draws blank.
61    pub fn create_window_at(&mut self, root: PathBuf, label: String) -> WindowId {
62        let id = WindowId(self.next_window_id);
63        self.next_window_id += 1;
64
65        let resources = self.window_resources();
66        let mut session = Window::new(id, label, root.clone(), resources);
67        session.terminal_width = self.terminal_width;
68        session.terminal_height = self.terminal_height;
69        let resolved_label = session.label.clone();
70        self.windows.insert(id, session);
71
72        // Same seed shape that `set_active_window` builds on
73        // first dive — installed eagerly so the window is
74        // immediately renderable from any code path that walks
75        // the windows map (preview rendering, embedded session
76        // panes, etc.).
77        if let Some((buf, state, metadata, event_log, mgr, vs)) =
78            self.build_fresh_layout_if_needed(id)
79        {
80            if let Some(s) = self.windows.get_mut(&id) {
81                s.buffers.set_splits((mgr, vs));
82                s.buffers.insert(buf, state);
83                s.buffer_metadata.insert(buf, metadata);
84                s.event_logs.insert(buf, event_log);
85            }
86        }
87
88        self.plugin_manager.read().unwrap().run_hook(
89            "window_created",
90            HookArgs::WindowCreated {
91                id: id.0,
92                label: resolved_label,
93                root: root.to_string_lossy().into_owned(),
94            },
95        );
96
97        id
98    }
99
100    /// Atomic "create a new window seeded with an agent terminal"
101    /// entry point. Used by Orchestrator's new-session flow.
102    ///
103    /// Unlike `create_window_at`, this path deliberately does NOT
104    /// seed an empty `[No Name]` buffer up front — the terminal
105    /// becomes the window's seed via `create_plugin_terminal`'s
106    /// no-active-split branch, so the new window is born with a
107    /// single tab (the terminal) instead of `[No Name] | <agent>`.
108    ///
109    /// The eager-seed invariant `create_window_at` upholds
110    /// ("window is renderable immediately after returning") still
111    /// holds here: the call to `create_plugin_terminal` runs
112    /// synchronously on the same thread before this function
113    /// yields, installing the terminal-rooted split layout before
114    /// any other code can observe the window. The `window_created`
115    /// hook is intentionally fired *after* the terminal is wired
116    /// up so plugin handlers see the new window in its final
117    /// shape, not the half-built intermediate state.
118    ///
119    /// `root` must be absolute; the plugin-command dispatcher
120    /// validates this before reaching here.
121    pub fn create_window_with_terminal(
122        &mut self,
123        root: PathBuf,
124        label: String,
125        cwd: Option<PathBuf>,
126        command: Option<Vec<String>>,
127        title: Option<String>,
128    ) -> Result<(WindowId, fresh_core::TerminalId, fresh_core::BufferId), String> {
129        let id = WindowId(self.next_window_id);
130        self.next_window_id += 1;
131
132        let resources = self.window_resources();
133        let mut session = Window::new(id, label, root.clone(), resources);
134        session.terminal_width = self.terminal_width;
135        session.terminal_height = self.terminal_height;
136        let resolved_label = session.label.clone();
137        self.windows.insert(id, session);
138
139        // Dive into the new window before spawning the terminal
140        // so `Window::create_plugin_terminal` operates on a window
141        // with `splits.is_none()` — that's the "no active_split"
142        // branch which seeds the layout rooted at the terminal
143        // buffer. We bypass `set_active_window`'s
144        // `build_fresh_layout_if_needed` call (which would install
145        // a `[No Name]` seed) by writing the active-window pointer
146        // directly.
147        let previous_id = self.active_window;
148        self.active_window = id;
149        self.working_dir = root.clone();
150
151        let spawn_result = {
152            let target = self
153                .windows
154                .get_mut(&id)
155                .expect("just-inserted window must be present");
156            target.create_plugin_terminal(
157                cwd.or_else(|| Some(root.clone())),
158                None, // no split direction — let the no-layout branch seed
159                None,
160                true,  // focus — newly spawned terminal is the seed
161                false, // ephemeral by default; orchestrator owns persistence
162                command,
163                title.filter(|t| !t.is_empty()),
164            )
165        };
166
167        let (terminal_id, buffer_id, _split_id) = match spawn_result {
168            Ok(triple) => triple,
169            Err(e) => {
170                // Roll back: tear down the half-built window and
171                // restore the previous active pointer so the user
172                // isn't stranded on an empty window when the PTY
173                // spawn fails (missing binary, permission denied,
174                // out of PTYs, ...).
175                self.windows.remove(&id);
176                self.active_window = previous_id;
177                if let Some(prev) = self.windows.get(&previous_id) {
178                    self.working_dir = prev.root.clone();
179                }
180                return Err(e);
181            }
182        };
183
184        // Register the leader pid with the new window's
185        // process_groups so window-level signal operations reach
186        // the spawned group. Mirrors `create_plugin_terminal`'s
187        // registration in the active-target path of
188        // `handle_create_terminal`, but kept here because we
189        // bypass that dispatcher.
190        if let Some(pid) = self
191            .windows
192            .get(&id)
193            .and_then(|w| w.terminal_manager.get(terminal_id))
194            .and_then(|h| h.pid())
195        {
196            let pg_label = format!("terminal #{}", terminal_id.0);
197            if let Some(win) = self.windows.get_mut(&id) {
198                win.process_groups.register(pid, pg_label);
199            }
200        }
201
202        // Resize the newly-active window's PTYs (mirrors
203        // `set_active_window`'s post-dive resize so the seeded
204        // terminal renders into the right cell rect on its first
205        // frame).
206        if let Some(win) = self.windows.get_mut(&id) {
207            win.resize_visible_terminals();
208        }
209
210        // Plugin lifecycle: fire `window_created` first, then
211        // `active_window_changed`. Order mirrors the
212        // `create_window_at` + `set_active_window` sequence the
213        // orchestrator previously chained — plugin handlers that
214        // care about either event see the same payload order.
215        self.plugin_manager.read().unwrap().run_hook(
216            "window_created",
217            HookArgs::WindowCreated {
218                id: id.0,
219                label: resolved_label,
220                root: root.to_string_lossy().into_owned(),
221            },
222        );
223        if previous_id != id {
224            self.plugin_manager.read().unwrap().run_hook(
225                "active_window_changed",
226                HookArgs::ActiveWindowChanged {
227                    previous_id: Some(previous_id.0),
228                    active_id: id.0,
229                },
230            );
231        }
232        #[cfg(feature = "plugins")]
233        self.update_plugin_state_snapshot();
234        #[cfg(feature = "plugins")]
235        self.plugin_manager.read().unwrap().run_hook(
236            "buffer_activated",
237            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
238        );
239
240        Ok((id, terminal_id, buffer_id))
241    }
242
243    /// Switch the active window to `id`.
244    ///
245    /// Pointer write: every per-window field
246    /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
247    /// already lives on `Window`, so flipping `active_window` is the
248    /// whole switch. Diving into a never-activated window seeds it
249    /// with a fresh empty buffer + SplitManager so the renderer
250    /// finds a populated `splits` field.
251    ///
252    /// No-op when `id` is already active. Logs and returns when
253    /// `id` is unknown — the design treats unknown ids as a plugin
254    /// bug (caller verifies with `listWindows`), not a recoverable
255    /// error worth surfacing through the channel.
256    pub fn set_active_window(&mut self, id: WindowId) {
257        if self.active_window == id {
258            return;
259        }
260        if !self.windows.contains_key(&id) {
261            tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
262            return;
263        }
264
265        let previous_id = self.active_window;
266
267        // Snapshot the new root before mutating fields that borrow
268        // self.windows.
269        let new_root = self.windows[&id].root.clone();
270
271        // For a never-activated incoming window, allocate a fresh
272        // seed buffer + SplitManager rooted at it. The state is
273        // installed into the incoming window's `buffers` map after
274        // the active pointer moves.
275        let fresh_layout = self.build_fresh_layout_if_needed(id);
276
277        // Pointer write — that's the whole switch.
278        self.active_window = id;
279        self.working_dir = new_root;
280
281        // For a never-activated incoming window, install the freshly
282        // built layout into the window's `splits` field and attach
283        // the seed buffer.
284        if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
285            if let Some(s) = self.windows.get_mut(&id) {
286                s.buffers.set_splits((mgr, vs));
287                s.buffers.insert(buf, state);
288                s.buffer_metadata.insert(buf, metadata);
289                s.event_logs.insert(buf, event_log);
290            }
291        }
292
293        // Refresh the plugin state snapshot so `getCwd()` (and every
294        // other snapshot field) reflects the window we just switched
295        // to *before* the `active_window_changed` hook runs. Without
296        // this, plugins that read `editor.getCwd()` — Live Grep, file
297        // finders, etc. — keep targeting the previous window's project
298        // after a dive, surfacing the wrong project's files.
299        #[cfg(feature = "plugins")]
300        self.update_plugin_state_snapshot();
301
302        self.plugin_manager.read().unwrap().run_hook(
303            "active_window_changed",
304            HookArgs::ActiveWindowChanged {
305                previous_id: Some(previous_id.0),
306                active_id: id.0,
307            },
308        );
309
310        // Resize the newly-active window's visible terminal PTYs to
311        // match their dive-view split rects. Without this, a session
312        // that was just previewed in the orchestrator picker
313        // (`render_session_preview_into_rect` resizes PTYs to the
314        // embed rect — typically ~half the terminal's height) keeps
315        // drawing at that smaller size after the dive, leaving the
316        // bottom of the dive view blank until something else triggers
317        // a resize. Same applies for the inverse: dive away while a
318        // session has a small split, dive back when the window is
319        // bigger — the terminal needs the new dimensions.
320        if let Some(win) = self.windows.get_mut(&id) {
321            win.resize_visible_terminals();
322        }
323    }
324
325    /// Cycle to the next open window in the workspace.
326    ///
327    /// Windows are ordered by their numeric `WindowId` (which is
328    /// monotonically assigned by `create_window_at`), so "next"
329    /// reads in creation order with wrap-around. No-op when only
330    /// one window is open (issue #2031).
331    pub fn next_window(&mut self) {
332        self.cycle_active_window(1);
333    }
334
335    /// Cycle to the previous open window. See [`Self::next_window`]
336    /// for ordering.
337    pub fn prev_window(&mut self) {
338        self.cycle_active_window(-1);
339    }
340
341    /// Step `delta` positions through the open windows (positive =
342    /// forward, negative = backward), wrapping around at the ends.
343    /// Centralises the cycle logic shared by `next_window` and
344    /// `prev_window` so both directions stay in sync if the
345    /// underlying ordering changes (e.g. user-controlled reorder).
346    fn cycle_active_window(&mut self, delta: isize) {
347        let mut ids: Vec<WindowId> = self.windows.keys().copied().collect();
348        if ids.len() <= 1 {
349            return;
350        }
351        ids.sort_by_key(|id| id.0);
352        let current_pos = match ids.iter().position(|id| *id == self.active_window) {
353            Some(pos) => pos as isize,
354            None => 0,
355        };
356        let len = ids.len() as isize;
357        let next_pos = (((current_pos + delta) % len) + len) % len;
358        let next_id = ids[next_pos as usize];
359        self.set_active_window(next_id);
360    }
361
362    /// Build a fresh seed buffer + split layout for `id` if that
363    /// window is missing either a split tree or any buffer to back
364    /// it. Returns `None` when the window is unknown or already
365    /// populated. The caller is responsible for installing the
366    /// returned tuple into the window's fields.
367    ///
368    /// Both branches (no splits, or splits but empty buffer map)
369    /// are pathological: render walks the active buffer and would
370    /// panic at `expect("active buffer must be present")` when the
371    /// split manager points at a buffer id that isn't in
372    /// `window.buffers`.
373    ///
374    /// Factored out of `set_active_window` so other call sites that
375    /// need to populate an inert window shell can share the same
376    /// seed-construction logic.
377    pub(crate) fn build_fresh_layout_if_needed(
378        &mut self,
379        id: WindowId,
380    ) -> Option<(
381        fresh_core::BufferId,
382        crate::state::EditorState,
383        crate::app::types::BufferMetadata,
384        crate::model::event::EventLog,
385        SplitManager,
386        HashMap<crate::model::event::LeafId, SplitViewState>,
387    )> {
388        if !self
389            .windows
390            .get(&id)
391            .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
392        {
393            return None;
394        }
395        let buf = self.alloc_buffer_id();
396        let mut state = crate::state::EditorState::new(
397            self.terminal_width,
398            self.terminal_height,
399            self.config.editor.large_file_threshold_bytes as usize,
400            std::sync::Arc::clone(&self.authority.filesystem),
401        );
402        state
403            .margins
404            .configure_for_line_numbers(self.config.editor.line_numbers);
405        state
406            .buffer
407            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
408        let metadata = crate::app::types::BufferMetadata::new();
409        let event_log = crate::model::event::EventLog::new();
410        let manager = SplitManager::new(buf);
411        let active_leaf = manager.active_split();
412        let mut view_states = HashMap::new();
413        view_states.insert(
414            active_leaf,
415            SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
416        );
417        Some((buf, state, metadata, event_log, manager, view_states))
418    }
419
420    /// Eagerly initialise an inactive session's per-session
421    /// state without diving. Useful for plugins (Orchestrator) that
422    /// want to pay the warm-up cost (file-tree walk, ignore
423    /// matcher, etc.) ahead of the user's first dive.
424    ///
425    /// In the current build this is a placeholder — file
426    /// explorer rebuilds and LSP boot still happen on first dive.
427    /// The API exists so callers don't have to be rewritten when
428    /// eager warm-up wires up later.
429    pub fn prewarm_window(&mut self, id: WindowId) {
430        if id == self.active_window {
431            return;
432        }
433        if !self.windows.contains_key(&id) {
434            tracing::warn!("prewarm_window: unknown session id {id}");
435        }
436        // Placeholder for eager warm-up of file_explorer / LSP.
437    }
438
439    /// Remove a buffer from whichever window holds it. Returns the
440    /// removed `EditorState` if the buffer was found. Step 0c: each
441    /// buffer lives in exactly one window, so this is at most one
442    /// successful removal.
443    pub(crate) fn detach_buffer_from_all_windows(
444        &mut self,
445        buffer_id: fresh_core::BufferId,
446    ) -> Option<crate::state::EditorState> {
447        for w in self.windows.values_mut() {
448            if let Some(state) = w.buffers.remove(&buffer_id) {
449                return Some(state);
450            }
451        }
452        None
453    }
454
455    /// Close a session and drop its `Session` entry. Refuses to
456    /// close the currently active session — the caller must switch
457    /// to a different session first. Refuses to close the base
458    /// session (`WindowId(1)`) — that's the editor's anchor.
459    ///
460    /// Returns `true` on success, `false` on rejection.
461    pub fn close_window(&mut self, id: WindowId) -> bool {
462        if id == WindowId(1) {
463            tracing::warn!("close_window: refusing to close the base session (id 1)");
464            return false;
465        }
466        if id == self.active_window {
467            tracing::warn!(
468                "close_window: refusing to close the active session (id {id}); \
469                 switch first via setActiveWindow"
470            );
471            return false;
472        }
473        if self.windows.remove(&id).is_none() {
474            tracing::warn!("close_window: unknown session id {id}");
475            return false;
476        }
477
478        self.plugin_manager
479            .read()
480            .unwrap()
481            .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
482
483        true
484    }
485}