fresh/app/lifecycle.rs
1//! Editor-lifecycle methods: quit, restart, session/detach control,
2//! focus/resize hooks, theme/settings queries, escape-sequence + clipboard
3//! piping, and the should_quit confirmation flow that walks modified buffers.
4
5use super::*;
6
7impl Editor {
8 /// Check if the editor should quit
9 pub fn should_quit(&self) -> bool {
10 self.should_quit
11 }
12
13 /// Check if the client should detach (keep server running)
14 pub fn should_detach(&self) -> bool {
15 self.should_detach
16 }
17
18 /// Clear the detach flag (after processing)
19 pub fn clear_detach(&mut self) {
20 self.should_detach = false;
21 }
22
23 /// Set session mode (use hardware cursor only, no REVERSED style for software cursor)
24 pub fn set_session_mode(&mut self, session_mode: bool) {
25 self.session_mode = session_mode;
26 self.clipboard.set_session_mode(session_mode);
27 // Also set custom context for command palette filtering
28 if session_mode {
29 self.active_window_mut()
30 .active_custom_contexts
31 .insert(crate::types::context_keys::SESSION_MODE.to_string());
32 } else {
33 self.active_window_mut()
34 .active_custom_contexts
35 .remove(crate::types::context_keys::SESSION_MODE);
36 }
37 }
38
39 /// Check if running in session mode
40 pub fn is_session_mode(&self) -> bool {
41 self.session_mode
42 }
43
44 /// Mark that the backend does not render a hardware cursor.
45 /// When set, the renderer always draws a software cursor indicator.
46 pub fn set_software_cursor_only(&mut self, enabled: bool) {
47 self.software_cursor_only = enabled;
48 }
49
50 /// Set the session name for display in status bar.
51 ///
52 /// When a session name is set, the recovery service is reinitialized
53 /// to use a session-scoped recovery directory so each named session's
54 /// recovery data is isolated.
55 pub fn set_session_name(&mut self, name: Option<String>) {
56 if let Some(ref session_name) = name {
57 let base_recovery_dir = self.dir_context.recovery_dir();
58 let scope = crate::services::recovery::RecoveryScope::Session {
59 name: session_name.clone(),
60 };
61 let recovery_config = RecoveryConfig {
62 enabled: self.recovery_service.lock().unwrap().is_enabled(),
63 ..RecoveryConfig::default()
64 };
65 // Replace the shared service's contents in place — the
66 // `Arc<Mutex>` is cloned into every window, so we must not
67 // swap the `Arc` itself (that would desync the windows).
68 *self.recovery_service.lock().unwrap() =
69 RecoveryService::with_scope(recovery_config, &base_recovery_dir, &scope);
70 }
71 self.session_name = name;
72 }
73
74 /// Get the session name (for status bar display)
75 pub fn session_name(&self) -> Option<&str> {
76 self.session_name.as_deref()
77 }
78
79 /// Queue escape sequences to be sent to the client (session mode only)
80 pub fn queue_escape_sequences(&mut self, sequences: &[u8]) {
81 self.pending_escape_sequences.extend_from_slice(sequences);
82 }
83
84 /// Take pending escape sequences, clearing the queue
85 pub fn take_pending_escape_sequences(&mut self) -> Vec<u8> {
86 std::mem::take(&mut self.pending_escape_sequences)
87 }
88
89 /// Take pending clipboard data queued in session mode, clearing the request
90 pub fn take_pending_clipboard(
91 &mut self,
92 ) -> Option<crate::services::clipboard::PendingClipboard> {
93 self.clipboard.take_pending_clipboard()
94 }
95
96 /// Check if the editor should restart with a new working directory
97 pub fn should_restart(&self) -> bool {
98 self.restart_with_dir.is_some()
99 }
100
101 /// Take the restart directory, clearing the restart request
102 /// Returns the new working directory if a restart was requested
103 pub fn take_restart_dir(&mut self) -> Option<PathBuf> {
104 self.restart_with_dir.take()
105 }
106
107 /// Request the editor to restart with a new working directory
108 /// This triggers a clean shutdown and restart with the new project root
109 /// Request a full hardware terminal clear and redraw on the next frame.
110 /// Used after external commands have messed up the terminal state.
111 pub fn request_full_redraw(&mut self) {
112 self.full_redraw_requested = true;
113 }
114
115 /// Check if a full redraw was requested, and clear the flag.
116 pub fn take_full_redraw_request(&mut self) -> bool {
117 let requested = self.full_redraw_requested;
118 self.full_redraw_requested = false;
119 requested
120 }
121
122 /// Request the event loop to suspend the editor process (SIGTSTP on Unix).
123 /// The loop tears down terminal modes, raises the signal, then re-enables
124 /// modes once the shell sends SIGCONT (e.g. via `fg`).
125 pub fn request_suspend(&mut self) {
126 self.suspend_requested = true;
127 }
128
129 /// Check if a suspend was requested, and clear the flag.
130 pub fn take_suspend_request(&mut self) -> bool {
131 let requested = self.suspend_requested;
132 self.suspend_requested = false;
133 requested
134 }
135
136 pub fn request_restart(&mut self, new_working_dir: PathBuf) {
137 tracing::info!(
138 "Restart requested with new working directory: {}",
139 new_working_dir.display()
140 );
141 self.restart_with_dir = Some(new_working_dir);
142 // Also signal quit so the event loop exits
143 self.should_quit = true;
144 }
145
146 /// Get the active theme (read lock).
147 pub fn theme(&self) -> std::sync::RwLockReadGuard<'_, crate::view::theme::Theme> {
148 self.theme.read().unwrap()
149 }
150
151 /// Check if the settings dialog is open and visible
152 pub fn is_settings_open(&self) -> bool {
153 self.settings_state.as_ref().is_some_and(|s| s.visible)
154 }
155
156 /// Request the editor to quit
157 pub fn quit(&mut self) {
158 // Check for unsaved buffers (all are auto-persisted when hot_exit is enabled)
159 let modified_count = self.count_modified_buffers_needing_prompt();
160 if modified_count == 0 && self.config.editor.confirm_quit {
161 // No dirty buffers, but the user has opted into a
162 // safety-net confirmation for a stray Ctrl+Q (issue #2030).
163 let msg = t!("prompt.quit_confirm").to_string();
164 self.start_prompt(msg, PromptType::ConfirmQuit);
165 return;
166 }
167 if modified_count > 0 {
168 let save_key = t!("prompt.key.save").to_string();
169 let cancel_key = t!("prompt.key.cancel").to_string();
170 let hot_exit = self.config.editor.hot_exit;
171
172 let discard_key = t!("prompt.key.discard").to_string();
173 let msg = if hot_exit {
174 // With hot exit: offer save, discard, quit-without-saving (recoverable), or cancel
175 let quit_key = t!("prompt.key.quit").to_string();
176 if modified_count == 1 {
177 t!(
178 "prompt.quit_modified_hot_one",
179 save_key = save_key,
180 discard_key = discard_key,
181 quit_key = quit_key,
182 cancel_key = cancel_key
183 )
184 .to_string()
185 } else {
186 t!(
187 "prompt.quit_modified_hot_many",
188 count = modified_count,
189 save_key = save_key,
190 discard_key = discard_key,
191 quit_key = quit_key,
192 cancel_key = cancel_key
193 )
194 .to_string()
195 }
196 } else {
197 // Without hot exit: offer save, discard, or cancel
198 if modified_count == 1 {
199 t!(
200 "prompt.quit_modified_one",
201 save_key = save_key,
202 discard_key = discard_key,
203 cancel_key = cancel_key
204 )
205 .to_string()
206 } else {
207 t!(
208 "prompt.quit_modified_many",
209 count = modified_count,
210 save_key = save_key,
211 discard_key = discard_key,
212 cancel_key = cancel_key
213 )
214 .to_string()
215 }
216 };
217 self.start_prompt(msg, PromptType::ConfirmQuitWithModified);
218 } else {
219 self.should_quit = true;
220 }
221 }
222
223 /// Count modified buffers that would require a save prompt on quit.
224 ///
225 /// When `hot_exit` is enabled, unnamed buffers are excluded (they are
226 /// automatically recovered across sessions), but file-backed modified
227 /// buffers still trigger a prompt with a "recoverable" option.
228 /// When `auto_save_enabled` is true, file-backed buffers are excluded
229 /// (they will be saved to disk on exit).
230 fn count_modified_buffers_needing_prompt(&self) -> usize {
231 let hot_exit = self.config.editor.hot_exit;
232 let auto_save = self.config.editor.auto_save_enabled;
233
234 self.windows
235 .get(&self.active_window)
236 .map(|w| &w.buffers)
237 .expect("active window present")
238 .iter()
239 .filter(|(buffer_id, state)| {
240 if !state.buffer.is_modified() {
241 return false;
242 }
243 if let Some(meta) = self.active_window().buffer_metadata.get(buffer_id) {
244 if let Some(path) = meta.file_path() {
245 let is_unnamed = path.as_os_str().is_empty();
246 if is_unnamed && hot_exit {
247 return false; // unnamed buffer, auto-recovered via hot exit
248 }
249 if !is_unnamed && auto_save {
250 return false; // file-backed, will be auto-saved on exit
251 }
252 }
253 }
254 true
255 })
256 .count()
257 }
258
259 /// Handle terminal focus gained event
260 pub fn focus_gained(&mut self) {
261 self.plugin_manager.read().unwrap().run_hook(
262 "focus_gained",
263 crate::services::plugins::hooks::HookArgs::FocusGained {},
264 );
265 }
266
267 /// Dispatch a raw terminal event into the editor.
268 ///
269 /// Async clipboard pastes are anchored in the buffer (a floating
270 /// "▍" placeholder), not gated on an input queue, so input is
271 /// dispatched immediately even while a paste is in flight. The
272 /// pasted text lands at its anchor when it arrives without
273 /// disturbing the live cursor.
274 ///
275 /// Returns whether the editor wants the next frame redrawn.
276 pub fn handle_input_event(&mut self, event: crossterm::event::Event) -> anyhow::Result<bool> {
277 use crossterm::event::{Event as Ev, KeyEventKind};
278
279 match event {
280 Ev::Key(key_event) if key_event.kind == KeyEventKind::Press => {
281 let key_code = format!("{:?}", key_event.code);
282 let modifiers = format!("{:?}", key_event.modifiers);
283 self.active_window_mut()
284 .log_keystroke(&key_code, &modifiers);
285 let translated = self.key_translator().translate(key_event);
286 self.handle_key(translated.code, translated.modifiers)?;
287 // If `paste()` just took the async placeholder path,
288 // skip the otherwise-automatic render for this
289 // keystroke. The placeholder is sitting in the
290 // buffer; the next render that fires for any other
291 // reason (typing, mouse, the paste resolving, the
292 // deadline expiring) will pick it up. Saves one full
293 // `terminal.draw` cycle per Ctrl+V — on a slow
294 // renderer that's the dominant component of the
295 // user-visible paste latency.
296 if self.take_paste_slow_path_armed() {
297 Ok(false)
298 } else {
299 Ok(true)
300 }
301 }
302 Ev::Mouse(mouse_event) => self.handle_mouse(mouse_event),
303 Ev::Resize(w, h) => {
304 self.resize(w, h);
305 Ok(true)
306 }
307 Ev::Paste(text) => {
308 // Terminal-initiated bracketed paste — no async read
309 // needed, the terminal already harvested the clipboard.
310 // When a floating modal / dock owns the keyboard the
311 // paste belongs to its focused text field, not the
312 // buffer underneath; route there first (and let a modal
313 // with no text field focused swallow it) before falling
314 // back to the buffer/prompt paste path.
315 if !self.paste_bracketed_into_focused_panel(&text) {
316 self.paste_text(text);
317 }
318 Ok(true)
319 }
320 Ev::FocusGained => {
321 self.focus_gained();
322 Ok(true)
323 }
324 _ => Ok(false),
325 }
326 }
327
328 /// Adopt new terminal (screen) dimensions, then re-derive the whole
329 /// layout. This is the OS-terminal-resize entry point; it only
330 /// records the new screen size and defers everything else to the
331 /// single layout funnel, [`Editor::relayout`].
332 pub fn resize(&mut self, width: u16, height: u16) {
333 // Editor's canonical screen dimensions (used to seed new windows).
334 self.terminal_width = width;
335 self.terminal_height = height;
336 self.relayout();
337 }
338
339 /// The single layout funnel. Every event that can change the on-screen
340 /// geometry — an OS terminal resize, toggling/dragging the dock,
341 /// toggling or dragging the file explorer, creating/closing/resizing a
342 /// split — mutates only the relevant source-of-truth state and then
343 /// calls this. `relayout` reads that state, derives the authoritative
344 /// geometry once, and pushes it *down* (one-directional) to every
345 /// consumer: split viewports, terminal PTYs (for all windows), the
346 /// dock / floating-panel rerender, and the plugin `resize` hook.
347 ///
348 /// It is intentionally cheap to call redundantly: terminal PTY resizes
349 /// are idempotent (the PTY layer drops no-op size changes) and the
350 /// plugin hook is signature-deduped, so callers never need to decide
351 /// "did this actually change the layout?" — they just call `relayout`.
352 pub fn relayout(&mut self) {
353 // Derive the dock width from its placement (the source of truth),
354 // exactly as the renderer's `compute_dock_split` does, so the
355 // geometry we push down matches what gets painted.
356 let dock_cols = self.dock_cols();
357 let (width, height) = (self.terminal_width, self.terminal_height);
358
359 // Push the derived geometry down to every window. The dock is
360 // editor-global, so every window — not just the active one —
361 // sizes its terminals for the post-dock chrome, ready for a
362 // dive without a stale first frame.
363 for window in self.windows.values_mut() {
364 window.apply_layout(width, height, dock_cols);
365 }
366
367 self.notify_layout_changed();
368 }
369
370 /// Effective width (cols) the left dock currently claims, or `0` when
371 /// no dock is shown / the terminal is too narrow for one. Delegates to
372 /// the renderer's `compute_dock_split` so this and the paint path can
373 /// never disagree about how wide the dock is.
374 pub(crate) fn dock_cols(&self) -> u16 {
375 let size = ratatui::layout::Rect::new(0, 0, self.terminal_width, self.terminal_height);
376 self.compute_dock_split(size)
377 .0
378 .map(|dock| dock.width)
379 .unwrap_or(0)
380 }
381
382 /// Fire the plugin `resize` hook and rerender mounted panels, but only
383 /// when the content geometry plugins observe has actually changed since
384 /// the last notification. The dedupe is load-bearing: the orchestrator
385 /// reacts to `resize` by re-issuing the dock's `dock_width`, which loops
386 /// back through `relayout`; without the signature guard that would
387 /// re-fire every frame. Once the dock width settles the signature stops
388 /// changing and the cascade stops.
389 fn notify_layout_changed(&mut self) {
390 let dock_cols = self.dock_cols();
391 // File-explorer width of the active window, measured against the
392 // post-dock chrome (matches the renderer and `resize_visible_terminals`).
393 let fe_cols = {
394 let win = self.active_window();
395 if win.file_explorer_visible {
396 win.file_explorer_width
397 .to_cols(self.terminal_width.saturating_sub(dock_cols))
398 } else {
399 0
400 }
401 };
402 let signature = (
403 self.terminal_width,
404 self.terminal_height,
405 dock_cols,
406 fe_cols,
407 );
408 if self.last_layout_signature == Some(signature) {
409 return;
410 }
411 self.last_layout_signature = Some(signature);
412
413 // Refresh the plugin-facing snapshot BEFORE firing the resize
414 // hook. Without this, the orchestrator's resize handler reads
415 // `editor.getViewport()` from a snapshot whose dimensions still
416 // reflect the pre-resize size — the one-way ratchet in
417 // `buildOpenSpec` then sees `old > old` and skips the update,
418 // leaving the picker stuck small. Updating the snapshot here lets
419 // plugins observe the new dimensions when they react to the hook.
420 #[cfg(feature = "plugins")]
421 self.update_plugin_state_snapshot();
422
423 // Notify plugins of the layout change so they can adjust their own
424 // layouts. The hook still reports the screen dimensions; plugins
425 // that care about their available area read `getViewport()` (which
426 // reflects the dock / file-explorer carve-out) from the snapshot
427 // refreshed just above.
428 self.plugin_manager.read().unwrap().run_hook(
429 "resize",
430 fresh_core::hooks::HookArgs::Resize {
431 width: self.terminal_width,
432 height: self.terminal_height,
433 },
434 );
435
436 // If a floating widget panel is currently mounted (the
437 // Orchestrator picker / dock, New-Session form, plugin overlays),
438 // its cached `entries` were laid out against the old geometry —
439 // re-render against the new one so column widths, side borders and
440 // embed rects all reflect the new chrome (Bug 13). The hook above
441 // lets plugins update their spec; this rerender picks up either the
442 // updated spec or the existing spec at the new width.
443 for panel_id in [
444 self.dock.as_ref().map(|f| f.panel_id),
445 self.floating_widget_panel.as_ref().map(|f| f.panel_id),
446 ]
447 .into_iter()
448 .flatten()
449 {
450 self.rerender_widget_panel(panel_id);
451 }
452 }
453}
454
455impl crate::app::window::Window {
456 /// Adopt the geometry handed down by [`Editor::relayout`]: cache the
457 /// screen dimensions and the editor-global dock width, reseed every
458 /// split viewport against the post-dock editor width, and resize the
459 /// visible terminal PTYs. Per-split viewport dimensions are refined
460 /// again at paint time by `sync_viewport_to_content`; terminals have
461 /// no such paint-time sync, which is why their PTY size must be pushed
462 /// here.
463 pub fn apply_layout(&mut self, width: u16, height: u16, dock_cols: u16) {
464 self.terminal_width = width;
465 self.terminal_height = height;
466 self.dock_cols = dock_cols;
467
468 let editor_width = width.saturating_sub(dock_cols);
469 if let Some(view_states) = self.split_view_states_mut() {
470 for view_state in view_states.values_mut() {
471 view_state.viewport.resize(editor_width, height);
472 }
473 }
474
475 self.resize_visible_terminals();
476
477 // The editor narrowed/widened (dock toggle or drag, file explorer,
478 // window resize, split change). Re-pin each visible split's active
479 // tab into view at its NEW width — otherwise a tab that was flush
480 // against the right edge scrolls off when the pane shrinks and the
481 // tab-scroll offset is never revisited. Use each split's real area
482 // width (dock/explorer/split-aware), not the whole-window width, so
483 // a half-width vertical split scrolls correctly too.
484 let visible: Vec<(
485 crate::model::event::LeafId,
486 crate::model::event::BufferId,
487 u16,
488 )> = match self.buffers.splits() {
489 Some((mgr, _)) => mgr
490 .get_visible_buffers(self.editor_content_area())
491 .into_iter()
492 .map(|(split_id, buffer_id, area)| (split_id, buffer_id, area.width))
493 .collect(),
494 None => Vec::new(),
495 };
496 for (split_id, buffer_id, tab_width) in visible {
497 self.ensure_active_tab_visible(split_id, buffer_id, tab_width);
498 }
499 }
500}