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.is_enabled(),
63 ..RecoveryConfig::default()
64 };
65 self.recovery_service =
66 RecoveryService::with_scope(recovery_config, &base_recovery_dir, &scope);
67 }
68 self.session_name = name;
69 }
70
71 /// Get the session name (for status bar display)
72 pub fn session_name(&self) -> Option<&str> {
73 self.session_name.as_deref()
74 }
75
76 /// Queue escape sequences to be sent to the client (session mode only)
77 pub fn queue_escape_sequences(&mut self, sequences: &[u8]) {
78 self.pending_escape_sequences.extend_from_slice(sequences);
79 }
80
81 /// Take pending escape sequences, clearing the queue
82 pub fn take_pending_escape_sequences(&mut self) -> Vec<u8> {
83 std::mem::take(&mut self.pending_escape_sequences)
84 }
85
86 /// Take pending clipboard data queued in session mode, clearing the request
87 pub fn take_pending_clipboard(
88 &mut self,
89 ) -> Option<crate::services::clipboard::PendingClipboard> {
90 self.clipboard.take_pending_clipboard()
91 }
92
93 /// Check if the editor should restart with a new working directory
94 pub fn should_restart(&self) -> bool {
95 self.restart_with_dir.is_some()
96 }
97
98 /// Take the restart directory, clearing the restart request
99 /// Returns the new working directory if a restart was requested
100 pub fn take_restart_dir(&mut self) -> Option<PathBuf> {
101 self.restart_with_dir.take()
102 }
103
104 /// Request the editor to restart with a new working directory
105 /// This triggers a clean shutdown and restart with the new project root
106 /// Request a full hardware terminal clear and redraw on the next frame.
107 /// Used after external commands have messed up the terminal state.
108 pub fn request_full_redraw(&mut self) {
109 self.full_redraw_requested = true;
110 }
111
112 /// Check if a full redraw was requested, and clear the flag.
113 pub fn take_full_redraw_request(&mut self) -> bool {
114 let requested = self.full_redraw_requested;
115 self.full_redraw_requested = false;
116 requested
117 }
118
119 /// Request the event loop to suspend the editor process (SIGTSTP on Unix).
120 /// The loop tears down terminal modes, raises the signal, then re-enables
121 /// modes once the shell sends SIGCONT (e.g. via `fg`).
122 pub fn request_suspend(&mut self) {
123 self.suspend_requested = true;
124 }
125
126 /// Check if a suspend was requested, and clear the flag.
127 pub fn take_suspend_request(&mut self) -> bool {
128 let requested = self.suspend_requested;
129 self.suspend_requested = false;
130 requested
131 }
132
133 pub fn request_restart(&mut self, new_working_dir: PathBuf) {
134 tracing::info!(
135 "Restart requested with new working directory: {}",
136 new_working_dir.display()
137 );
138 self.restart_with_dir = Some(new_working_dir);
139 // Also signal quit so the event loop exits
140 self.should_quit = true;
141 }
142
143 /// Get the active theme (read lock).
144 pub fn theme(&self) -> std::sync::RwLockReadGuard<'_, crate::view::theme::Theme> {
145 self.theme.read().unwrap()
146 }
147
148 /// Check if the settings dialog is open and visible
149 pub fn is_settings_open(&self) -> bool {
150 self.settings_state.as_ref().is_some_and(|s| s.visible)
151 }
152
153 /// Request the editor to quit
154 pub fn quit(&mut self) {
155 // Check for unsaved buffers (all are auto-persisted when hot_exit is enabled)
156 let modified_count = self.count_modified_buffers_needing_prompt();
157 if modified_count == 0 && self.config.editor.confirm_quit {
158 // No dirty buffers, but the user has opted into a
159 // safety-net confirmation for a stray Ctrl+Q (issue #2030).
160 let msg = t!("prompt.quit_confirm").to_string();
161 self.start_prompt(msg, PromptType::ConfirmQuit);
162 return;
163 }
164 if modified_count > 0 {
165 let save_key = t!("prompt.key.save").to_string();
166 let cancel_key = t!("prompt.key.cancel").to_string();
167 let hot_exit = self.config.editor.hot_exit;
168
169 let discard_key = t!("prompt.key.discard").to_string();
170 let msg = if hot_exit {
171 // With hot exit: offer save, discard, quit-without-saving (recoverable), or cancel
172 let quit_key = t!("prompt.key.quit").to_string();
173 if modified_count == 1 {
174 t!(
175 "prompt.quit_modified_hot_one",
176 save_key = save_key,
177 discard_key = discard_key,
178 quit_key = quit_key,
179 cancel_key = cancel_key
180 )
181 .to_string()
182 } else {
183 t!(
184 "prompt.quit_modified_hot_many",
185 count = modified_count,
186 save_key = save_key,
187 discard_key = discard_key,
188 quit_key = quit_key,
189 cancel_key = cancel_key
190 )
191 .to_string()
192 }
193 } else {
194 // Without hot exit: offer save, discard, or cancel
195 if modified_count == 1 {
196 t!(
197 "prompt.quit_modified_one",
198 save_key = save_key,
199 discard_key = discard_key,
200 cancel_key = cancel_key
201 )
202 .to_string()
203 } else {
204 t!(
205 "prompt.quit_modified_many",
206 count = modified_count,
207 save_key = save_key,
208 discard_key = discard_key,
209 cancel_key = cancel_key
210 )
211 .to_string()
212 }
213 };
214 self.start_prompt(msg, PromptType::ConfirmQuitWithModified);
215 } else {
216 self.should_quit = true;
217 }
218 }
219
220 /// Count modified buffers that would require a save prompt on quit.
221 ///
222 /// When `hot_exit` is enabled, unnamed buffers are excluded (they are
223 /// automatically recovered across sessions), but file-backed modified
224 /// buffers still trigger a prompt with a "recoverable" option.
225 /// When `auto_save_enabled` is true, file-backed buffers are excluded
226 /// (they will be saved to disk on exit).
227 fn count_modified_buffers_needing_prompt(&self) -> usize {
228 let hot_exit = self.config.editor.hot_exit;
229 let auto_save = self.config.editor.auto_save_enabled;
230
231 self.windows
232 .get(&self.active_window)
233 .map(|w| &w.buffers)
234 .expect("active window present")
235 .iter()
236 .filter(|(buffer_id, state)| {
237 if !state.buffer.is_modified() {
238 return false;
239 }
240 if let Some(meta) = self.active_window().buffer_metadata.get(buffer_id) {
241 if let Some(path) = meta.file_path() {
242 let is_unnamed = path.as_os_str().is_empty();
243 if is_unnamed && hot_exit {
244 return false; // unnamed buffer, auto-recovered via hot exit
245 }
246 if !is_unnamed && auto_save {
247 return false; // file-backed, will be auto-saved on exit
248 }
249 }
250 }
251 true
252 })
253 .count()
254 }
255
256 /// Handle terminal focus gained event
257 pub fn focus_gained(&mut self) {
258 self.plugin_manager.read().unwrap().run_hook(
259 "focus_gained",
260 crate::services::plugins::hooks::HookArgs::FocusGained {},
261 );
262 }
263
264 /// Resize all buffers to match new terminal size. Loops over every
265 /// `Window` so each one updates its own split viewports and visible
266 /// terminal PTYs; the plugin `resize` hook fires once for the editor
267 /// as a whole.
268 pub fn resize(&mut self, width: u16, height: u16) {
269 // Editor's canonical screen dimensions (used to seed new windows).
270 self.terminal_width = width;
271 self.terminal_height = height;
272
273 for window in self.windows.values_mut() {
274 window.resize(width, height);
275 }
276
277 // Refresh the plugin-facing snapshot BEFORE firing the
278 // resize hook. Without this, the orchestrator's resize
279 // handler reads `editor.getViewport()` from a snapshot
280 // whose `viewport.height` still reflects the pre-resize
281 // size — the one-way ratchet in `buildOpenSpec` then sees
282 // `old > old` and skips the update, leaving the picker
283 // stuck small even after a terminal-grow event. (The
284 // ratchet itself is correct; the input it consumes was
285 // stale.) Updating the snapshot here lets plugins observe
286 // the new dimensions when they react to the hook.
287 #[cfg(feature = "plugins")]
288 self.update_plugin_state_snapshot();
289
290 // Notify plugins of the resize so they can adjust layouts.
291 self.plugin_manager.read().unwrap().run_hook(
292 "resize",
293 fresh_core::hooks::HookArgs::Resize { width, height },
294 );
295
296 // If a floating widget panel is currently mounted (the
297 // Orchestrator picker, New-Session form, plugin overlays),
298 // its cached `entries` were laid out against the old screen
299 // width — re-render against the new one so column widths,
300 // side borders and embed rects all reflect the new
301 // dimensions (Bug 13). The hook above lets plugins update
302 // their spec; this rerender picks up either the updated
303 // spec or the existing spec at the new width.
304 if let Some(panel_id) = self.floating_widget_panel.as_ref().map(|f| f.panel_id) {
305 self.rerender_widget_panel(panel_id);
306 }
307 }
308}
309
310impl crate::app::window::Window {
311 /// Adopt the new terminal dimensions for this window: update the
312 /// cached `terminal_width` / `terminal_height`, resize every split
313 /// viewport, and resize any visible terminal PTYs.
314 pub fn resize(&mut self, width: u16, height: u16) {
315 self.terminal_width = width;
316 self.terminal_height = height;
317
318 if let Some(view_states) = self.split_view_states_mut() {
319 for view_state in view_states.values_mut() {
320 view_state.viewport.resize(width, height);
321 }
322 }
323
324 self.resize_visible_terminals();
325 }
326}