par-term 0.49.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
//! Tab reopen (session undo) and tab-bar resize helpers.
//!
//! Extracted from `lifecycle` to keep create/close logic separate from
//! the undo-stack / preserved-shell reopen path.
//!
//! Contains:
//! - `reopen_closed_tab` — restore most-recently-closed tab from undo stack
//! - `handle_tab_bar_resize_after_add` — resize existing tabs when tab bar appears

use std::sync::Arc;

use super::super::window_state::WindowState;
use super::ClosedTabInfo;

/// The toast shown when session undo consumes an entry whose daemon window
/// was killed on close: the tab cannot be restored, and restoring an older
/// entry in its place would resurrect the wrong tab (UX.md M3).
pub(crate) fn ended_mux_window_toast(info: &ClosedTabInfo) -> Option<String> {
    info.ended_mux_window.map(|window| {
        format!("That tab was par-mux window @{window} and was ended; it can't be reopened")
    })
}

impl WindowState {
    /// Reopen the most recently closed tab at its original position
    pub fn reopen_closed_tab(&mut self) {
        // Prune expired entries
        if self.config.load().session_restore.session_undo_timeout_secs > 0 {
            let timeout = std::time::Duration::from_secs(
                self.config.load().session_restore.session_undo_timeout_secs as u64,
            );
            let now = std::time::Instant::now();
            self.overlay_state
                .closed_tabs
                .retain(|info| now.duration_since(info.closed_at) < timeout);
        }

        let info = match self.overlay_state.closed_tabs.pop_front() {
            Some(info) => info,
            None => {
                self.show_toast("No recently closed tabs");
                return;
            }
        };

        // A killed par-mux window is unrestorable. Consume the entry and say
        // so; falling through to the older entries would silently restore an
        // unrelated tab.
        if let Some(toast) = ended_mux_window_toast(&info) {
            self.show_toast(toast);
            return;
        }

        // D7 (UX.md M4): a HIDDEN mux tab's entry re-shows the tab. The
        // daemon window kept running and the tab never left the manager, so
        // there is nothing to rebuild — and no max_tabs slot is consumed.
        // The entry is consumed even when the tab is gone (session ended,
        // detach closed it): falling through to older entries would restore
        // an unrelated tab, the same trap the killed-window refusal closes.
        if let Some(hidden) = &info.hidden_mux_window {
            let live = self.tab_manager.get_tab(hidden.tab_id).is_some()
                && self.mux_window_for_tab(hidden.tab_id) == Some(hidden.window_id);
            if live && let Some(tab) = self.tab_manager.get_tab_mut(hidden.tab_id) {
                let was_hidden = tab.is_hidden;
                tab.is_hidden = false;
                self.tab_manager.switch_to(hidden.tab_id);
                if was_hidden {
                    self.show_toast(format!(
                        "par-mux: window @{} re-shown (it kept running)",
                        hidden.window_id
                    ));
                }
            } else {
                self.show_toast(
                    "That par-mux tab is no longer attached — its window kept running in the daemon",
                );
            }
            self.focus_state.needs_redraw = true;
            self.request_redraw();
            return;
        }

        // Check max tabs limit
        if self.config.load().tabs.max_tabs > 0
            && self.tab_manager.tab_count() >= self.config.load().tabs.max_tabs
        {
            log::warn!(
                "Cannot reopen tab: max_tabs limit ({}) reached",
                self.config.load().tabs.max_tabs
            );
            self.show_toast("Cannot reopen tab: max tabs limit reached");
            // Put the info back so the user can try again after closing another tab
            self.overlay_state.closed_tabs.push_front(info);
            return;
        }

        // Remember tab count before restoring to detect tab bar visibility change
        let old_tab_count = self.tab_manager.tab_count();

        if let Some(hidden_tab) = info.hidden_tab {
            // Preserved shell: re-insert the live Tab.
            //
            // The undo entry can outlive its id: a tab moved in from another
            // window may have claimed it while this one sat on the stack. The
            // insert renumbers the tab in that case, so every lookup below goes
            // through the returned id — the stale one would resize and refresh
            // the wrong tab.
            let tab_id = self.tab_manager.insert_tab_at(hidden_tab, info.index);

            // Handle tab bar visibility change
            self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);

            // Restart refresh task and resize terminal to match current window
            if let Some(window) = &self.window
                && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
            {
                tab.start_refresh_task(
                    Arc::clone(&self.runtime),
                    Arc::clone(window),
                    self.config.load().rendering.max_fps,
                    self.config.load().power.inactive_tab_fps,
                );

                // Invalidate cell cache so content is re-rendered
                tab.active_cache_mut().cells = None;

                // try_lock: intentional — tab switch resize in sync event loop.
                // On miss: the newly active tab uses previous dimensions until next Resized.
                if let Some(renderer) = &self.renderer
                    && let Ok(mut term) = tab.terminal.try_write()
                {
                    let (cols, rows) = renderer.grid_size();
                    let cell_width = renderer.cell_width();
                    let cell_height = renderer.cell_height();
                    let width_px = (cols as f32 * cell_width) as usize;
                    let height_px = (rows as f32 * cell_height) as usize;
                    term.set_cell_dimensions(cell_width as u32, cell_height as u32);
                    if let Err(e) = term.resize_with_pixels(cols, rows, width_px, height_px) {
                        crate::debug_error!(
                            "TERMINAL",
                            "resize_with_pixels failed (reopen_preserved): {e}"
                        );
                    }
                }
            }

            self.play_alert_sound(crate::config::AlertEvent::NewTab);
            self.show_toast("Tab restored (shell kept running)");
            self.focus_state.needs_redraw = true;
            self.request_redraw();
        } else {
            // Metadata-only: create a new tab from CWD (existing behavior)
            let grid_size = self.renderer.as_ref().map(|r| r.grid_size());

            match self.tab_manager.new_tab_with_cwd(
                &self.config.load(),
                Arc::clone(&self.runtime),
                info.cwd,
                grid_size,
            ) {
                Ok(tab_id) => {
                    // Handle tab bar visibility change
                    self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);

                    // Restore title, custom color, and the user-named/icon
                    // state that keeps a later OSC title from overwriting
                    // the user's name (UX.md B15/TW3).
                    if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
                        if !info.has_default_title {
                            tab.set_title(&info.title);
                        }
                        tab.custom_color = info.custom_color;
                        tab.user_named = info.user_named;
                        tab.custom_icon = info.custom_icon.clone();
                    }

                    // Move tab to its original position
                    self.tab_manager.move_tab_to_index(tab_id, info.index);

                    // Start refresh task and resize terminal
                    if let Some(window) = &self.window
                        && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
                    {
                        tab.start_refresh_task(
                            Arc::clone(&self.runtime),
                            Arc::clone(window),
                            self.config.load().rendering.max_fps,
                            self.config.load().power.inactive_tab_fps,
                        );

                        // try_lock: intentional — new pane initialization in sync event loop.
                        // On miss: pane terminal keeps default dimensions; fixed on next Resized.
                        if let Some(renderer) = &self.renderer
                            && let Ok(mut term) = tab.terminal.try_write()
                        {
                            let (cols, rows) = renderer.grid_size();
                            let cell_width = renderer.cell_width();
                            let cell_height = renderer.cell_height();
                            let width_px = (cols as f32 * cell_width) as usize;
                            let height_px = (rows as f32 * cell_height) as usize;
                            term.set_cell_dimensions(cell_width as u32, cell_height as u32);
                            if let Err(e) = term.resize_with_pixels(cols, rows, width_px, height_px)
                            {
                                crate::debug_error!(
                                    "TERMINAL",
                                    "resize_with_pixels failed (reopen_cwd): {e}"
                                );
                            }
                        }
                    }

                    // Restore pane layout if present
                    if let Some(pane_layout) = &info.pane_layout
                        && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
                    {
                        tab.restore_pane_layout(
                            pane_layout,
                            &self.config.load(),
                            Arc::clone(&self.runtime),
                        );
                        // Start refresh tasks for restored panes
                        if let Some(window) = &self.window
                            && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
                        {
                            tab.start_pane_refresh_tasks(
                                Arc::clone(&self.runtime),
                                Arc::clone(window),
                                self.config.load().rendering.max_fps,
                                self.config.load().power.inactive_tab_fps,
                            );
                        }
                    }

                    self.play_alert_sound(crate::config::AlertEvent::NewTab);
                    self.show_toast("Tab restored");
                    self.focus_state.needs_redraw = true;
                    self.request_redraw();
                }
                Err(e) => {
                    log::error!("Failed to reopen closed tab: {}", e);
                    self.show_persistent_toast("Failed to reopen tab");
                }
            }
        }
    }

    /// Handle tab bar visibility change after adding a tab.
    /// Resizes existing tabs if the tab bar appearance changed (e.g., from 1 to 2 tabs).
    pub(super) fn handle_tab_bar_resize_after_add(
        &mut self,
        old_tab_count: usize,
        new_tab_id: crate::tab::TabId,
    ) {
        let new_tab_count = self.tab_manager.tab_count();
        let old_tab_bar_height = self
            .tab_bar_ui
            .get_height(old_tab_count, &self.config.load());
        let new_tab_bar_height = self
            .tab_bar_ui
            .get_height(new_tab_count, &self.config.load());
        let old_tab_bar_width = self
            .tab_bar_ui
            .get_width(old_tab_count, &self.config.load());
        let new_tab_bar_width = self
            .tab_bar_ui
            .get_width(new_tab_count, &self.config.load());

        if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
            || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
            && let Some(renderer) = &mut self.renderer
            && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
                self.config.load().tabs.tab_bar_position,
                renderer,
                new_tab_bar_height,
                new_tab_bar_width,
            )
        {
            let cell_width = renderer.cell_width();
            let cell_height = renderer.cell_height();
            let width_px = (new_cols as f32 * cell_width) as usize;
            let height_px = (new_rows as f32 * cell_height) as usize;

            for tab in self.tab_manager.tabs_mut() {
                if tab.id != new_tab_id {
                    // try_lock: intentional — tab bar resize loop in sync event loop.
                    // On miss: this tab is not resized; corrected on the next Resized event.
                    if let Ok(mut term) = tab.terminal.try_write() {
                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
                        if let Err(e) =
                            term.resize_with_pixels(new_cols, new_rows, width_px, height_px)
                        {
                            crate::debug_error!(
                                "TERMINAL",
                                "resize_with_pixels failed (tab_bar_resize): {e}"
                            );
                        }
                    }
                    tab.active_cache_mut().cells = None;
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn info(ended_mux_window: Option<u64>) -> ClosedTabInfo {
        ClosedTabInfo {
            cwd: None,
            title: "work".to_string(),
            has_default_title: false,
            index: 0,
            closed_at: std::time::Instant::now(),
            pane_layout: None,
            custom_color: None,
            user_named: false,
            custom_icon: None,
            hidden_tab: None,
            ended_mux_window,
            hidden_mux_window: None,
        }
    }

    #[test]
    fn killed_mux_window_entry_refuses_reopen() {
        let toast = ended_mux_window_toast(&info(Some(7))).expect("killed entry refuses");
        assert!(toast.contains("@7"), "names the window: {toast}");
        assert!(toast.contains("can't be reopened"), "{toast}");
    }

    #[test]
    fn local_entry_has_no_refusal_toast() {
        assert!(ended_mux_window_toast(&info(None)).is_none());
    }

    /// UX.md B15/TW3: a reopened renamed tab keeps its name and icon, and a
    /// later OSC-driven title update does not overwrite the user's name.
    #[test]
    fn reopened_renamed_tab_keeps_name_icon_and_survives_osc() {
        let runtime = std::sync::Arc::new(
            tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
                .expect("build test runtime"),
        );
        let mut config = crate::config::Config::default();
        // The metadata-only undo path is where B15 bites: preserve-shell
        // re-inserts the live Tab and keeps everything by construction.
        config.session_restore.session_undo_preserve_shell = false;
        // Keep the close out of the running-job confirmation dialog.
        config.shell.confirm_close_running_jobs = false;
        let mut ws =
            crate::app::window_state::WindowState::new(config, std::sync::Arc::clone(&runtime));

        // Two local tabs so closing one never tears down the window.
        for _ in 0..2 {
            ws.tab_manager
                .new_tab_with_cwd(&ws.config.load(), runtime.clone(), None, None)
                .expect("create tab");
        }

        // Apply the rename and icon exactly as the tab bar does.
        let renamed_id = ws.tab_manager.tabs()[0].id;
        ws.handle_tab_bar_action_after_render(crate::tab_bar_ui::TabBarAction::RenameTab(
            renamed_id,
            "work".to_string(),
        ));
        ws.handle_tab_bar_action_after_render(crate::tab_bar_ui::TabBarAction::SetTabIcon(
            renamed_id,
            Some("🔥".to_string()),
        ));
        ws.tab_manager.switch_to(renamed_id);
        {
            let tab = ws.tab_manager.get_tab(renamed_id).expect("renamed tab");
            assert_eq!(tab.title, "work");
            assert!(tab.user_named);
            assert_eq!(tab.custom_icon.as_deref(), Some("🔥"));
        }

        ws.close_current_tab();

        // The undo entry captured the user's name and icon.
        let info = ws.overlay_state.closed_tabs.front().expect("undo entry");
        assert!(info.user_named, "capture must record user_named");
        assert_eq!(
            info.custom_icon.as_deref(),
            Some("🔥"),
            "capture must record the icon"
        );

        ws.reopen_closed_tab();

        let tab = ws.tab_manager.active_tab().expect("reopened tab is active");
        assert_eq!(tab.title, "work", "reopened tab keeps the user's name");
        assert!(tab.user_named, "reopened tab is still user-named");
        assert_eq!(
            tab.custom_icon.as_deref(),
            Some("🔥"),
            "reopened tab keeps the icon"
        );

        // A later OSC-driven title update must not overwrite the user's name.
        let tab_id = tab.id;
        let (title_mode, remote_format, remote_osc_priority) = {
            let cfg = ws.config.load();
            (
                cfg.tabs.tab_title_mode,
                cfg.tabs.remote_tab_title_format,
                cfg.tabs.remote_tab_title_osc_priority,
            )
        };
        let tab = ws.tab_manager.get_tab_mut(tab_id).expect("reopened tab");
        tab.update_title(title_mode, remote_format, remote_osc_priority);
        assert_eq!(
            tab.title, "work",
            "OSC/CWD auto-title must not overwrite a user-named reopened tab"
        );
    }
}