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 /// Switch the active window to `id`.
101 ///
102 /// Pointer write: every per-window field
103 /// (panel_ids / file_mod_times / file_explorer / lsp / splits)
104 /// already lives on `Window`, so flipping `active_window` is the
105 /// whole switch. Diving into a never-activated window seeds it
106 /// with a fresh empty buffer + SplitManager so the renderer
107 /// finds a populated `splits` field.
108 ///
109 /// No-op when `id` is already active. Logs and returns when
110 /// `id` is unknown — the design treats unknown ids as a plugin
111 /// bug (caller verifies with `listWindows`), not a recoverable
112 /// error worth surfacing through the channel.
113 pub fn set_active_window(&mut self, id: WindowId) {
114 if self.active_window == id {
115 return;
116 }
117 if !self.windows.contains_key(&id) {
118 tracing::warn!("set_active_window: unknown window id {id}; active window unchanged");
119 return;
120 }
121
122 let previous_id = self.active_window;
123
124 // Snapshot the new root before mutating fields that borrow
125 // self.windows.
126 let new_root = self.windows[&id].root.clone();
127
128 // For a never-activated incoming window, allocate a fresh
129 // seed buffer + SplitManager rooted at it. The state is
130 // installed into the incoming window's `buffers` map after
131 // the active pointer moves.
132 let fresh_layout = self.build_fresh_layout_if_needed(id);
133
134 // Pointer write — that's the whole switch.
135 self.active_window = id;
136 self.working_dir = new_root;
137
138 // For a never-activated incoming window, install the freshly
139 // built layout into the window's `splits` field and attach
140 // the seed buffer.
141 if let Some((buf, state, metadata, event_log, mgr, vs)) = fresh_layout {
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 "active_window_changed",
152 HookArgs::ActiveWindowChanged {
153 previous_id: Some(previous_id.0),
154 active_id: id.0,
155 },
156 );
157
158 // Resize the newly-active window's visible terminal PTYs to
159 // match their dive-view split rects. Without this, a session
160 // that was just previewed in the orchestrator picker
161 // (`render_session_preview_into_rect` resizes PTYs to the
162 // embed rect — typically ~half the terminal's height) keeps
163 // drawing at that smaller size after the dive, leaving the
164 // bottom of the dive view blank until something else triggers
165 // a resize. Same applies for the inverse: dive away while a
166 // session has a small split, dive back when the window is
167 // bigger — the terminal needs the new dimensions.
168 if let Some(win) = self.windows.get_mut(&id) {
169 win.resize_visible_terminals();
170 }
171 }
172
173 /// Build a fresh seed buffer + split layout for `id` if that
174 /// window is missing either a split tree or any buffer to back
175 /// it. Returns `None` when the window is unknown or already
176 /// populated. The caller is responsible for installing the
177 /// returned tuple into the window's fields.
178 ///
179 /// Both branches (no splits, or splits but empty buffer map)
180 /// are pathological: render walks the active buffer and would
181 /// panic at `expect("active buffer must be present")` when the
182 /// split manager points at a buffer id that isn't in
183 /// `window.buffers`.
184 ///
185 /// Factored out of `set_active_window` so other call sites that
186 /// need to populate an inert window shell can share the same
187 /// seed-construction logic.
188 pub(crate) fn build_fresh_layout_if_needed(
189 &mut self,
190 id: WindowId,
191 ) -> Option<(
192 fresh_core::BufferId,
193 crate::state::EditorState,
194 crate::app::types::BufferMetadata,
195 crate::model::event::EventLog,
196 SplitManager,
197 HashMap<crate::model::event::LeafId, SplitViewState>,
198 )> {
199 if !self
200 .windows
201 .get(&id)
202 .is_some_and(|s| s.buffers.splits().is_none() || s.buffers.len() == 0)
203 {
204 return None;
205 }
206 let buf = self.alloc_buffer_id();
207 let mut state = crate::state::EditorState::new(
208 self.terminal_width,
209 self.terminal_height,
210 self.config.editor.large_file_threshold_bytes as usize,
211 std::sync::Arc::clone(&self.authority.filesystem),
212 );
213 state
214 .margins
215 .configure_for_line_numbers(self.config.editor.line_numbers);
216 state
217 .buffer
218 .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
219 let metadata = crate::app::types::BufferMetadata::new();
220 let event_log = crate::model::event::EventLog::new();
221 let manager = SplitManager::new(buf);
222 let active_leaf = manager.active_split();
223 let mut view_states = HashMap::new();
224 view_states.insert(
225 active_leaf,
226 SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
227 );
228 Some((buf, state, metadata, event_log, manager, view_states))
229 }
230
231 /// Eagerly initialise an inactive session's per-session
232 /// state without diving. Useful for plugins (Orchestrator) that
233 /// want to pay the warm-up cost (file-tree walk, ignore
234 /// matcher, etc.) ahead of the user's first dive.
235 ///
236 /// In the current build this is a placeholder — file
237 /// explorer rebuilds and LSP boot still happen on first dive.
238 /// The API exists so callers don't have to be rewritten when
239 /// eager warm-up wires up later.
240 pub fn prewarm_window(&mut self, id: WindowId) {
241 if id == self.active_window {
242 return;
243 }
244 if !self.windows.contains_key(&id) {
245 tracing::warn!("prewarm_window: unknown session id {id}");
246 }
247 // Placeholder for eager warm-up of file_explorer / LSP.
248 }
249
250 /// Remove a buffer from whichever window holds it. Returns the
251 /// removed `EditorState` if the buffer was found. Step 0c: each
252 /// buffer lives in exactly one window, so this is at most one
253 /// successful removal.
254 pub(crate) fn detach_buffer_from_all_windows(
255 &mut self,
256 buffer_id: fresh_core::BufferId,
257 ) -> Option<crate::state::EditorState> {
258 for w in self.windows.values_mut() {
259 if let Some(state) = w.buffers.remove(&buffer_id) {
260 return Some(state);
261 }
262 }
263 None
264 }
265
266 /// Close a session and drop its `Session` entry. Refuses to
267 /// close the currently active session — the caller must switch
268 /// to a different session first. Refuses to close the base
269 /// session (`WindowId(1)`) — that's the editor's anchor.
270 ///
271 /// Returns `true` on success, `false` on rejection.
272 pub fn close_window(&mut self, id: WindowId) -> bool {
273 if id == WindowId(1) {
274 tracing::warn!("close_window: refusing to close the base session (id 1)");
275 return false;
276 }
277 if id == self.active_window {
278 tracing::warn!(
279 "close_window: refusing to close the active session (id {id}); \
280 switch first via setActiveWindow"
281 );
282 return false;
283 }
284 if self.windows.remove(&id).is_none() {
285 tracing::warn!("close_window: unknown session id {id}");
286 return false;
287 }
288
289 self.plugin_manager
290 .read()
291 .unwrap()
292 .run_hook("window_closed", HookArgs::WindowClosed { id: id.0 });
293
294 true
295 }
296}