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