par-term 0.48.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Helper and utility methods for `WindowState`, plus the `Drop` implementation.
//!
//! Covers:
//! - DRY rendering helpers (`invalidate_tab_cache`, `request_redraw`, `clear_and_invalidate`)
//! - Window access helpers (`with_window`, AUD-033)
//! - Active-tab access helpers (`with_active_tab`, `with_active_tab_mut`, AUD-030)
//! - Debounced config save (`save_config_debounced`, `process_pending_config_save`)
//! - Anti-idle keep-alive logic
//! - Shutdown sequence (`perform_shutdown`)
//! - `Drop` implementation (fast-path window teardown)
//!
//! egui pointer / keyboard query helpers, modal-visibility queries, and scrollbar
//! visibility logic have been extracted to `ui_query_helpers.rs`.

use super::{ConfigSaveState, WindowState};
use crate::app::window_state::anti_idle::should_send_keep_alive;
use crate::tab::Tab;
use anyhow::Result;
use std::sync::Arc;

impl WindowState {
    // ========================================================================
    // DRY Helper Methods
    // ========================================================================

    /// Invalidate the active tab's cell cache, forcing regeneration on next render
    #[inline]
    pub(crate) fn invalidate_tab_cache(&mut self) {
        if let Some(tab) = self.tab_manager.active_tab_mut() {
            tab.active_cache_mut().cells = None;
        }
    }

    /// Request window redraw if window exists.
    ///
    /// Prefer this over the inline `if let Some(window) = &self.window { window.request_redraw() }`
    /// pattern (AUD-032).
    #[inline]
    pub(crate) fn request_redraw(&self) {
        if let Some(window) = &self.window {
            crate::debug_trace!("REDRAW", "request_redraw called");
            window.request_redraw();
        } else {
            crate::debug_trace!("REDRAW", "request_redraw called but no window");
        }
    }

    /// Run a closure with the winit `Window`, returning `None` when the window is absent.
    ///
    /// Use this instead of the inline `if let Some(window) = &self.window { ... }` pattern
    /// for one-shot operations on the window (cursor changes, title updates, etc.) (AUD-033).
    ///
    /// # Example
    /// ```ignore
    /// self.with_window(|w| w.set_cursor(cursor));
    /// ```
    #[inline]
    pub(crate) fn with_window<R>(&self, f: impl FnOnce(&winit::window::Window) -> R) -> Option<R> {
        self.window.as_deref().map(f)
    }

    /// Run a closure with the active tab (immutable), returning `None` when no tab is active.
    ///
    /// Use this instead of the inline `if let Some(tab) = self.tab_manager.active_tab() { ... }`
    /// pattern (AUD-030).
    #[inline]
    pub(crate) fn with_active_tab<R>(&self, f: impl FnOnce(&Tab) -> R) -> Option<R> {
        self.tab_manager.active_tab().map(f)
    }

    /// Run a closure with the active tab (mutable), returning `None` when no tab is active.
    ///
    /// Use this instead of the inline `if let Some(tab) = self.tab_manager.active_tab_mut() { ... }`
    /// pattern (AUD-030).
    #[inline]
    pub(crate) fn with_active_tab_mut<R>(&mut self, f: impl FnOnce(&mut Tab) -> R) -> Option<R> {
        self.tab_manager.active_tab_mut().map(f)
    }

    /// Clear renderer cells and invalidate cache (used when switching tabs)
    pub(crate) fn clear_and_invalidate(&mut self) {
        if let Some(renderer) = &mut self.renderer {
            renderer.clear_all_cells();
        }
        self.invalidate_tab_cache();
        self.focus_state.needs_redraw = true;
        self.request_redraw();
    }

    // ========================================================================
    // Debounced Config Save
    // ========================================================================

    /// Queue this window's current integration prompt state for the window
    /// manager to persist (through Settings' baseline when it is open).
    pub(crate) fn queue_integrations_change(&mut self) {
        let integrations = self.config.load().integrations.clone();
        self.render_loop
            .external_config_changes
            .push(super::ExternalConfigChange::Integrations(Box::new(
                integrations,
            )));
    }

    /// Save config with debouncing to prevent rapid concurrent writes.
    ///
    /// Multiple code paths may request config saves in quick succession (e.g.,
    /// user changes a setting, an agent modifies config, update checker records
    /// timestamp). This method batches those saves together.
    ///
    /// - If called within DEBOUNCE_INTERVAL of last save, marks a pending save
    ///   and returns immediately (no error).
    /// - If a save is already pending, just updates the pending flag (idempotent).
    ///
    /// Callers should invoke `process_pending_config_save()` periodically (e.g.,
    /// once per frame) to flush any deferred saves.
    pub(crate) fn save_config_debounced(&mut self) -> Result<()> {
        let now = std::time::Instant::now();
        let debounce_interval =
            std::time::Duration::from_millis(ConfigSaveState::DEBOUNCE_INTERVAL_MS);

        // Check if we're within the debounce window
        if let Some(last_save) = self.render_loop.config_save.last_save
            && now.duration_since(last_save) < debounce_interval
        {
            // Defer this save - mark as pending
            self.render_loop.config_save.pending_save = true;
            log::debug!(
                "Config save debounced (within {}ms window)",
                ConfigSaveState::DEBOUNCE_INTERVAL_MS
            );
            return Ok(());
        }

        // Perform the actual save
        self.config.load().save()?;
        self.render_loop.config_save.last_save = Some(now);
        self.render_loop.config_save.pending_save = false;
        log::debug!("Config saved immediately");
        Ok(())
    }

    /// Process any pending config save that was deferred by debouncing.
    ///
    /// Should be called once per frame (e.g., in the render loop) to ensure
    /// deferred saves are eventually flushed.
    ///
    /// Returns `true` if a save was performed, `false` if nothing was pending.
    pub(crate) fn process_pending_config_save(&mut self) -> bool {
        if !self.render_loop.config_save.pending_save {
            return false;
        }

        let now = std::time::Instant::now();
        let debounce_interval =
            std::time::Duration::from_millis(ConfigSaveState::DEBOUNCE_INTERVAL_MS);

        // Check if enough time has passed since last save
        if let Some(last_save) = self.render_loop.config_save.last_save
            && now.duration_since(last_save) < debounce_interval
        {
            // Still within debounce window, wait longer
            return false;
        }

        // Perform the pending save
        if let Err(e) = self.config.load().save() {
            log::error!("Failed to save pending config: {}", e);
        } else {
            log::debug!("Pending config save flushed");
        }

        self.render_loop.config_save.last_save = Some(now);
        self.render_loop.config_save.pending_save = false;
        true
    }

    // ========================================================================
    // Anti-idle
    // ========================================================================

    /// Check anti-idle timers and send keep-alive codes when due.
    ///
    /// Returns the next Instant when anti-idle should run, or None if disabled.
    pub(crate) fn handle_anti_idle(
        &mut self,
        now: std::time::Instant,
    ) -> Option<std::time::Instant> {
        if !self.config.load().notifications.anti_idle_enabled {
            return None;
        }

        let idle_threshold = std::time::Duration::from_secs(
            self.config.load().notifications.anti_idle_seconds.max(1),
        );
        let keep_alive_code = [self.config.load().notifications.anti_idle_code];
        let mut next_due: Option<std::time::Instant> = None;

        for tab in self.tab_manager.tabs_mut() {
            // Track activity on the terminal the user sees: the focused
            // pane's (the daemon mirror in a mux tab) with a fallback to
            // the tab terminal. Reading the hidden shell's generation in a
            // mux tab would make the tab look permanently idle — and
            // keep-alives belong to the daemon pane, where the SSH session
            // the feature exists to keep alive actually runs.
            let (focused_pane_id, terminal) = match tab.pane_manager().and_then(|pm| {
                pm.focused_pane()
                    .map(|pane| (pane.id, Arc::clone(&pane.terminal)))
            }) {
                Some((pane_id, terminal)) => (Some(pane_id), terminal),
                None => (None, Arc::clone(&tab.terminal)),
            };

            if let Ok(term) = terminal.try_read() {
                // Treat new terminal output as activity
                let current_generation = term.update_generation();
                if current_generation > tab.activity.anti_idle_last_generation {
                    tab.activity.anti_idle_last_generation = current_generation;
                    tab.activity.anti_idle_last_activity = now;
                }

                // If idle long enough, send keep-alive code
                if should_send_keep_alive(tab.activity.anti_idle_last_activity, now, idle_threshold)
                {
                    // Routed through the free-function form: the tabs_mut
                    // iteration holds the mutable tab borrow, so a &self
                    // method call would not compile here.
                    let routed = focused_pane_id.is_some_and(|pane_id| {
                        crate::app::tmux_handler::pane_write::route_mux_pane_write_state(
                            &self.tmux_state,
                            tab.id,
                            pane_id,
                            &keep_alive_code,
                        )
                    });
                    if routed || term.write(&keep_alive_code).is_ok() {
                        tab.activity.anti_idle_last_activity = now;
                    } else {
                        log::warn!("Failed to send anti-idle keep-alive for tab {}", tab.id);
                    }
                }

                // Compute next due time for this tab
                let elapsed = now.duration_since(tab.activity.anti_idle_last_activity);
                let remaining = if elapsed >= idle_threshold {
                    idle_threshold
                } else {
                    idle_threshold - elapsed
                };
                let candidate = now + remaining;
                next_due = Some(next_due.map_or(candidate, |prev| prev.min(candidate)));
            }
        }

        next_due
    }

    // ========================================================================
    // Shutdown
    // ========================================================================

    /// Perform the shutdown sequence (save state and set shutdown flag)
    pub(crate) fn perform_shutdown(&mut self) {
        // Save last working directory for "previous session" mode
        if self.config.load().shell.startup_directory_mode
            == crate::config::StartupDirectoryMode::Previous
            && let Some(tab) = self.tab_manager.active_tab()
            && let Ok(term) = tab.terminal.try_read()
            && let Some(cwd) = term.shell_integration_cwd()
        {
            log::info!("Saving last working directory: {}", cwd);
            self.config.rcu(|old| {
                let mut new = (**old).clone();
                if let Err(e) = new.save_last_working_directory(&cwd) {
                    log::warn!("Failed to save last working directory: {}", e);
                }
                Arc::new(new)
            });
        }

        // Set shutdown flag to stop redraw loop
        self.is_shutting_down = true;
        // Abort refresh tasks for all tabs
        for tab in self.tab_manager.tabs_mut() {
            if let Some(task) = tab.refresh_task.take() {
                task.abort();
            }
        }
        log::info!("Refresh tasks aborted, shutdown initiated");
    }
}

// ---------------------------------------------------------------------------
impl Drop for WindowState {
    fn drop(&mut self) {
        let t0 = std::time::Instant::now();
        log::info!("Shutting down window (fast path)");

        // Signal status bar polling threads to stop immediately.
        // They check the flag every 50ms, so by the time the auto-drop
        // calls join() later, the threads will already be exiting.
        self.status_bar_ui.signal_shutdown();

        // Save command history on a background thread (serializes in-memory, writes async)
        self.overlay_ui.command_history.save_background();

        // Set shutdown flag
        self.is_shutting_down = true;

        // Hide the window immediately for instant visual feedback
        if let Some(ref window) = self.window {
            window.set_visible(false);
            log::info!(
                "Window hidden for instant visual close (+{:.1}ms)",
                t0.elapsed().as_secs_f64() * 1000.0
            );
        }

        // Clean up egui state FIRST before any other resources are dropped
        self.egui.state = None;
        self.egui.ctx = None;

        // Drain all tabs from the manager (takes ownership without dropping)
        let mut tabs = self.tab_manager.drain_tabs();
        let tab_count = tabs.len();
        log::info!(
            "Fast shutdown: draining {} tabs (+{:.1}ms)",
            tab_count,
            t0.elapsed().as_secs_f64() * 1000.0
        );

        // Collect terminal Arcs and session loggers from all tabs and panes
        // BEFORE setting shutdown_fast. Cloning the Arc keeps TerminalManager
        // alive even after Tab/Pane is dropped. Session loggers are collected
        // so they can be stopped on a background thread instead of blocking.
        let mut terminal_arcs = Vec::new();
        let mut session_loggers = Vec::new();

        for tab in &mut tabs {
            // Stop refresh tasks (fast - just aborts tokio tasks)
            tab.stop_refresh_task();

            // Collect session logger for background stop
            session_loggers.push(Arc::clone(&tab.session_logger));

            // Clone terminal Arc before we mark shutdown_fast
            terminal_arcs.push(Arc::clone(&tab.terminal));

            // Also handle panes if this tab has splits
            if let Some(ref mut pm) = tab.pane_manager {
                for pane in pm.all_panes_mut() {
                    pane.stop_refresh_task();
                    session_loggers.push(Arc::clone(&pane.session_logger));
                    terminal_arcs.push(Arc::clone(&pane.terminal));
                    pane.shutdown_fast = true;
                }
            }

            // Mark tab for fast drop (skips sleep + kill in Tab::drop)
            tab.shutdown_fast = true;
        }

        // Pre-kill all PTY processes (sends SIGKILL, fast non-blocking)
        for arc in &terminal_arcs {
            if let Ok(mut term) = arc.try_write()
                && term.is_running()
            {
                let _ = term.kill();
            }
        }
        log::info!(
            "Pre-killed {} terminal sessions (+{:.1}ms)",
            terminal_arcs.len(),
            t0.elapsed().as_secs_f64() * 1000.0
        );

        // Drop tabs on main thread (fast - Tab::drop just returns immediately)
        drop(tabs);
        log::info!(
            "Tabs dropped (+{:.1}ms)",
            t0.elapsed().as_secs_f64() * 1000.0
        );

        // Fire-and-forget: stop session loggers on a background thread.
        // Each logger.stop() flushes buffered I/O which can block.
        if !session_loggers.is_empty() {
            let _ = std::thread::Builder::new()
                .name("logger-cleanup".into())
                .spawn(move || {
                    for logger_arc in session_loggers {
                        if let Some(ref mut logger) = *logger_arc.lock() {
                            let _ = logger.stop();
                        }
                    }
                });
        }

        // Fire-and-forget: drop the cloned terminal Arcs on background threads.
        // When our clone is the last reference, TerminalManager::drop runs,
        // which triggers PtySession::drop (up to 2s reader thread wait).
        // By running these in parallel, all sessions clean up concurrently.
        // We intentionally do NOT join these threads — the process is exiting
        // and the OS will reclaim all resources.
        for (i, arc) in terminal_arcs.into_iter().enumerate() {
            let _ = std::thread::Builder::new()
                .name(format!("pty-cleanup-{}", i))
                .spawn(move || {
                    let t = std::time::Instant::now();
                    drop(arc);
                    log::info!(
                        "pty-cleanup-{} finished in {:.1}ms",
                        i,
                        t.elapsed().as_secs_f64() * 1000.0
                    );
                });
        }

        log::info!(
            "Window shutdown complete ({} tabs, main thread blocked {:.1}ms)",
            tab_count,
            t0.elapsed().as_secs_f64() * 1000.0
        );
    }
}