Skip to main content

par_term/app/tab_ops/
lifecycle.rs

1//! Tab lifecycle operations: create, close, navigate.
2//!
3//! Reopen (session undo) and tab-bar resize helpers live in `tab_reopen`.
4
5use std::sync::Arc;
6
7use super::super::window_state::WindowState;
8use super::ClosedTabInfo;
9use crate::tmux::TmuxWindowId;
10use par_term_config::TabId;
11
12impl WindowState {
13    /// Create a new tab, or show profile picker if configured and profiles exist
14    pub fn new_tab_or_show_profiles(&mut self) {
15        if self.config.load().tabs.new_tab_shortcut_shows_profiles
16            && !self.overlay_ui.profile_manager.is_empty()
17        {
18            self.tab_bar_ui.show_new_tab_profile_menu = !self.tab_bar_ui.show_new_tab_profile_menu;
19            self.request_redraw();
20            log::info!("Toggled new-tab profile menu via shortcut");
21        } else {
22            self.new_tab();
23            log::info!("New tab created");
24        }
25    }
26
27    /// The daemon window `tab_id` mirrors, when a mux transport is
28    /// attached. `None` for local tabs and for every tab while no transport
29    /// is installed (the tmux gateway keeps its own separate state).
30    pub(crate) fn mux_window_for_tab(&self, tab_id: TabId) -> Option<TmuxWindowId> {
31        self.tmux_state.transport.as_ref()?;
32        self.tmux_state.tmux_sync.get_window(tab_id)
33    }
34
35    /// Create a new tab
36    pub fn new_tab(&mut self) {
37        // Check max tabs limit
38        if self.config.load().tabs.max_tabs > 0
39            && self.tab_manager.tab_count() >= self.config.load().tabs.max_tabs
40        {
41            log::warn!(
42                "Cannot create new tab: max_tabs limit ({}) reached",
43                self.config.load().tabs.max_tabs
44            );
45            return;
46        }
47
48        // While attached to a par-mux session, a new tab IS a daemon window
49        // (the tmux -CC model): ask the daemon, and its %window-add
50        // notification creates the display tab through the same path as
51        // every other daemon window. No local shell tab is created — the
52        // session outlives this client, so a local tab would be a window
53        // the daemon cannot see or manage.
54        let sent = self
55            .tmux_state
56            .transport
57            .as_ref()
58            .map(|t| t.send_command("new-window"));
59        if let Some(result) = sent {
60            match result {
61                Ok(_) => {
62                    log::info!("MUX: new-window sent; tab arrives via %window-add");
63                }
64                Err(e) => {
65                    log::error!("MUX: new-window failed: {e}");
66                    self.show_toast(format!("par-mux: new-window failed — {e}"));
67                }
68            }
69            self.request_redraw();
70            return;
71        }
72
73        // Remember tab count before creating new tab to detect tab bar visibility change
74        let old_tab_count = self.tab_manager.tab_count();
75
76        // Capture BEFORE creation — tab_manager switches active to the new tab inside new_tab()
77        let prior_active_idx = self.tab_manager.active_tab_index();
78
79        // Get current grid size from renderer to pass to new tab
80        // This accounts for possible tab bar height changes
81        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
82
83        match self.tab_manager.new_tab(
84            &self.config.load(),
85            Arc::clone(&self.runtime),
86            self.config.load().tabs.tab_inherit_cwd,
87            grid_size,
88        ) {
89            Ok(tab_id) => {
90                // Reposition new tab if configured
91                if self.config.load().tabs.new_tab_position
92                    == crate::config::NewTabPosition::AfterActive
93                    && let Some(idx) = prior_active_idx
94                {
95                    self.tab_manager.move_tab_to_index(tab_id, idx + 1);
96                }
97
98                // Check if tab bar visibility changed (e.g., from 1 to 2 tabs with WhenMultiple mode)
99                let new_tab_count = self.tab_manager.tab_count();
100                let old_tab_bar_height = self
101                    .tab_bar_ui
102                    .get_height(old_tab_count, &self.config.load());
103                let new_tab_bar_height = self
104                    .tab_bar_ui
105                    .get_height(new_tab_count, &self.config.load());
106                let old_tab_bar_width = self
107                    .tab_bar_ui
108                    .get_width(old_tab_count, &self.config.load());
109                let new_tab_bar_width = self
110                    .tab_bar_ui
111                    .get_width(new_tab_count, &self.config.load());
112
113                // If tab bar dimensions changed, update content offsets and resize ALL existing tabs
114                if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
115                    || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
116                    && let Some(renderer) = &mut self.renderer
117                    && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
118                        self.config.load().tabs.tab_bar_position,
119                        renderer,
120                        new_tab_bar_height,
121                        new_tab_bar_width,
122                    )
123                {
124                    let cell_width = renderer.cell_width();
125                    let cell_height = renderer.cell_height();
126                    let width_px = (new_cols as f32 * cell_width) as usize;
127                    let height_px = (new_rows as f32 * cell_height) as usize;
128
129                    // Resize all EXISTING tabs (not including the new one yet)
130                    for tab in self.tab_manager.tabs_mut() {
131                        if tab.id != tab_id {
132                            // try_lock: intentional — resize during new-tab creation in sync
133                            // event loop. On miss: this tab keeps old dimensions; corrected
134                            // on the next Resized event.
135                            if let Ok(mut term) = tab.terminal.try_write() {
136                                term.set_cell_dimensions(cell_width as u32, cell_height as u32);
137                                if let Err(e) =
138                                    term.resize_with_pixels(new_cols, new_rows, width_px, height_px)
139                                {
140                                    crate::debug_error!(
141                                        "TERMINAL",
142                                        "resize_with_pixels failed (new_tab): {e}"
143                                    );
144                                }
145                            }
146                            tab.active_cache_mut().cells = None;
147                        }
148                    }
149                    log::info!(
150                        "Tab bar appeared (position={:?}), resized existing tabs to {}x{}",
151                        self.config.load().tabs.tab_bar_position,
152                        new_cols,
153                        new_rows
154                    );
155                }
156
157                // Start refresh task for the new tab and resize to match window
158                if let Some(window) = &self.window
159                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
160                {
161                    tab.start_refresh_task(
162                        Arc::clone(&self.runtime),
163                        Arc::clone(window),
164                        self.config.load().rendering.max_fps,
165                        self.config.load().power.inactive_tab_fps,
166                    );
167
168                    // Resize terminal to match current renderer dimensions
169                    // (which now has the correct content offset)
170                    // try_lock: intentional — new-tab initialization in sync event loop.
171                    // On miss: the new tab starts with default PTY dimensions; corrected
172                    // on the next Resized event.
173                    if let Some(renderer) = &self.renderer
174                        && let Ok(mut term) = tab.terminal.try_write()
175                    {
176                        let (cols, rows) = renderer.grid_size();
177                        let cell_width = renderer.cell_width();
178                        let cell_height = renderer.cell_height();
179                        let width_px = (cols as f32 * cell_width) as usize;
180                        let height_px = (rows as f32 * cell_height) as usize;
181
182                        // Set cell dimensions
183                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
184
185                        // Resize terminal to match window size
186                        if let Err(e) = term.resize_with_pixels(cols, rows, width_px, height_px) {
187                            crate::debug_error!(
188                                "TERMINAL",
189                                "resize_with_pixels failed (new_tab_init): {e}"
190                            );
191                        }
192                        log::info!(
193                            "Resized new tab {} terminal to {}x{} ({}x{} px)",
194                            tab_id,
195                            cols,
196                            rows,
197                            width_px,
198                            height_px
199                        );
200                    }
201                }
202
203                // Play new tab alert sound if configured
204                self.play_alert_sound(crate::config::AlertEvent::NewTab);
205
206                self.focus_state.needs_redraw = true;
207                self.request_redraw();
208            }
209            Err(e) => {
210                log::error!("Failed to create new tab: {}", e);
211            }
212        }
213    }
214
215    /// Close the current tab
216    /// Returns true if the window should close (last tab was closed)
217    pub fn close_current_tab(&mut self) -> bool {
218        log::info!(
219            "[CLOSE_TAB] close_current_tab called, confirm_close_running_jobs={}",
220            self.config.load().shell.confirm_close_running_jobs
221        );
222
223        // Check if we need to show confirmation for running jobs
224        if self.config.load().shell.confirm_close_running_jobs
225            && let Some(command_name) = self.check_current_tab_running_job()
226            && let Some(tab) = self.tab_manager.active_tab()
227        {
228            let tab_id = tab.id;
229            let tab_title = if tab.title.is_empty() {
230                "Terminal".to_string()
231            } else {
232                tab.title.clone()
233            };
234            log::info!(
235                "[CLOSE_TAB] Showing close confirmation for tab {} with running command: {}",
236                tab_id,
237                command_name
238            );
239            self.overlay_ui
240                .close_confirmation_ui
241                .show_for_tab(tab_id, &tab_title, &command_name);
242            self.focus_state.needs_redraw = true;
243            self.request_redraw();
244            return false; // Don't close yet, waiting for confirmation
245        }
246
247        log::info!(
248            "[CLOSE_TAB] No running job detected or confirmation disabled, closing immediately"
249        );
250        self.close_current_tab_immediately()
251    }
252
253    /// Close the current tab immediately without confirmation
254    /// Returns true if the window should close (last tab was closed)
255    pub fn close_current_tab_immediately(&mut self) -> bool {
256        self.close_current_tab_inner(true)
257    }
258
259    /// The last-pane close of a mux tab (card 01a0d9b5568): the tab closes
260    /// locally and the daemon window KEEPS RUNNING — the detach-like
261    /// shape — so the window can come back on reattach. A TAB close (the
262    /// public method above) kills the daemon window instead.
263    #[cfg_attr(not(feature = "mux"), allow(dead_code))]
264    pub(crate) fn close_current_tab_keeping_mux_window(&mut self) -> bool {
265        self.close_current_tab_inner(false)
266    }
267
268    /// The shared close body. `kill_mux_window` decides a mux tab's
269    /// daemon-window fate: `true` for tab closes (kill-window — the
270    /// tmux -CC semantic, no stale mappings), `false` for the last-pane
271    /// close's recorded keep-the-window decision.
272    fn close_current_tab_inner(&mut self, kill_mux_window: bool) -> bool {
273        if let Some(tab_id) = self.tab_manager.active_tab_id() {
274            // If the tab being closed is the tmux gateway, send detach-client first so
275            // that tmux can cleanly detach rather than treating the disconnect as a crash
276            // (which may destroy the session if destroy-unattached is enabled).
277            let is_tmux_gateway = self.config.load().tmux.tmux_enabled
278                && self.tmux_state.tmux_gateway_tab_id == Some(tab_id)
279                && self.is_gateway_active();
280            if is_tmux_gateway {
281                self.write_to_gateway("detach-client\n");
282                self.disconnect_tmux_session();
283            }
284
285            // A mux tab mirrors a daemon window: closing it kills that
286            // window daemon-side, and the %window-close notification runs
287            // the tab teardown (close_tab_fast + unmap) — the same path as
288            // a shell exiting in the daemon pane. Killing (rather than
289            // detaching one window) matches tmux semantics and leaves no
290            // stale mappings behind; the SESSION survives via detach.
291            if kill_mux_window && let Some(window_id) = self.mux_window_for_tab(tab_id) {
292                let killed = self
293                    .tmux_state
294                    .transport
295                    .as_ref()
296                    .map(|t| t.send_command(&format!("kill-window -t @{window_id}")));
297                if matches!(&killed, Some(Ok(_))) {
298                    log::info!("MUX: kill-window @{} sent for tab {tab_id}", window_id);
299                    // No session-undo capture: a daemon window cannot
300                    // be restored from local metadata — reopening is a
301                    // new-window, not an undo.
302                    return false; // the tab closes when %window-close lands
303                }
304                if let Some(Err(e)) = &killed {
305                    log::error!("MUX: kill-window @{window_id} failed: {e}");
306                    self.show_toast(format!("par-mux: kill-window failed — {e}"));
307                    // Fall through to the local close: the daemon
308                    // window is unreachable from this client either way.
309                }
310            }
311
312            // Track whether this is a tmux display tab (non-gateway tab that shows tmux window
313            // content) so we can restore the hidden gateway tab after it is closed.
314            let is_tmux_display_tab = self.config.load().tmux.tmux_enabled
315                && self.tmux_state.tmux_gateway_tab_id.is_some()
316                && self.tmux_state.tmux_gateway_tab_id != Some(tab_id)
317                && self.is_gateway_active();
318
319            // Remember tab count before closing to detect tab bar visibility change
320            let old_tab_count = self.tab_manager.tab_count();
321            let old_tab_bar_height = self
322                .tab_bar_ui
323                .get_height(old_tab_count, &self.config.load());
324            let old_tab_bar_width = self
325                .tab_bar_ui
326                .get_width(old_tab_count, &self.config.load());
327
328            let is_last_tab = self.tab_manager.tab_count() <= 1;
329            let preserve_shell = self
330                .config
331                .load()
332                .session_restore
333                .session_undo_preserve_shell
334                && self.config.load().session_restore.session_undo_timeout_secs > 0;
335
336            // Capture closed tab metadata for session undo (before destroying the tab)
337            let is_last = if preserve_shell {
338                // Preserve mode: extract the live Tab and store it in ClosedTabInfo
339                if let Some(tab) = self.tab_manager.get_tab(tab_id) {
340                    let cwd = tab.get_cwd();
341                    let title = tab.title.clone();
342                    let has_default_title = tab.has_default_title;
343                    let custom_color = tab.custom_color;
344                    let index = self.tab_manager.active_tab_index().unwrap_or(0);
345
346                    if let Some((mut hidden_tab, is_empty)) = self.tab_manager.remove_tab(tab_id) {
347                        // Stop refresh task to prevent invisible redraws
348                        hidden_tab.stop_refresh_task();
349
350                        let info = ClosedTabInfo {
351                            cwd,
352                            title,
353                            has_default_title,
354                            index,
355                            closed_at: std::time::Instant::now(),
356                            pane_layout: None, // Preserved inside the hidden Tab itself
357                            custom_color,
358                            hidden_tab: Some(hidden_tab),
359                        };
360                        self.overlay_state.closed_tabs.push_front(info);
361                        while self.overlay_state.closed_tabs.len()
362                            > self.config.load().session_restore.session_undo_max_entries
363                        {
364                            self.overlay_state.closed_tabs.pop_back();
365                        }
366                        is_empty
367                    } else {
368                        // Fallback: tab disappeared between get and remove
369                        self.tab_manager.close_tab(tab_id)
370                    }
371                } else {
372                    self.tab_manager.close_tab(tab_id)
373                }
374            } else {
375                // Standard mode: capture metadata, then close (drops the Tab)
376                if self.config.load().session_restore.session_undo_timeout_secs > 0
377                    && let Some(tab) = self.tab_manager.get_tab(tab_id)
378                {
379                    let info = ClosedTabInfo {
380                        cwd: tab.get_cwd(),
381                        title: tab.title.clone(),
382                        has_default_title: tab.has_default_title,
383                        index: self.tab_manager.active_tab_index().unwrap_or(0),
384                        closed_at: std::time::Instant::now(),
385                        pane_layout: tab
386                            .pane_manager
387                            .as_ref()
388                            .and_then(|pm| pm.root())
389                            .map(crate::session::capture::capture_pane_node),
390                        custom_color: tab.custom_color,
391                        hidden_tab: None,
392                    };
393                    self.overlay_state.closed_tabs.push_front(info);
394                    while self.overlay_state.closed_tabs.len()
395                        > self.config.load().session_restore.session_undo_max_entries
396                    {
397                        self.overlay_state.closed_tabs.pop_back();
398                    }
399                }
400
401                self.tab_manager.close_tab(tab_id)
402            };
403
404            // After closing a tmux display tab, check if the hidden gateway tab needs to
405            // be restored.  This happens when the user manually closes all display tabs
406            // while still connected — the gateway tab was hidden but is the only one left.
407            if is_tmux_display_tab && self.is_gateway_active() {
408                let gateway_tab_id = self.tmux_state.tmux_gateway_tab_id;
409                let has_other_visible = self
410                    .tab_manager
411                    .tabs()
412                    .iter()
413                    .any(|t| !t.is_hidden && Some(t.id) != gateway_tab_id);
414                if !has_other_visible {
415                    self.show_gateway_tab();
416                    // Switch focus to the gateway tab so the user can interact with it
417                    if let Some(gtid) = gateway_tab_id {
418                        self.tab_manager.switch_to(gtid);
419                    }
420                }
421            }
422
423            // Play tab close alert sound if configured
424            self.play_alert_sound(crate::config::AlertEvent::TabClose);
425
426            // Show undo toast (only if not the last tab — window is closing)
427            if !is_last_tab {
428                let key_hint = self
429                    .config
430                    .load()
431                    .keybindings
432                    .iter()
433                    .find(|kb| kb.action == "reopen_closed_tab")
434                    .map(|kb| kb.key.clone())
435                    .unwrap_or_else(|| "keybinding".to_string());
436                let timeout = self.config.load().session_restore.session_undo_timeout_secs;
437                if timeout > 0 {
438                    self.show_toast(format!(
439                        "Tab closed. Press {} to undo ({timeout}s)",
440                        key_hint
441                    ));
442                }
443            }
444
445            // Check if tab bar visibility changed (e.g., from 2 to 1 tabs with WhenMultiple mode)
446            if !is_last {
447                let new_tab_count = self.tab_manager.tab_count();
448                let new_tab_bar_height = self
449                    .tab_bar_ui
450                    .get_height(new_tab_count, &self.config.load());
451                let new_tab_bar_width = self
452                    .tab_bar_ui
453                    .get_width(new_tab_count, &self.config.load());
454
455                if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
456                    || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
457                    && let Some(renderer) = &mut self.renderer
458                    && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
459                        self.config.load().tabs.tab_bar_position,
460                        renderer,
461                        new_tab_bar_height,
462                        new_tab_bar_width,
463                    )
464                {
465                    let cell_width = renderer.cell_width();
466                    let cell_height = renderer.cell_height();
467                    let width_px = (new_cols as f32 * cell_width) as usize;
468                    let height_px = (new_rows as f32 * cell_height) as usize;
469
470                    // Resize all remaining tabs
471                    for tab in self.tab_manager.tabs_mut() {
472                        // try_lock: intentional — tab close resize in sync event loop.
473                        // On miss: tab keeps old dimensions; fixed on the next Resized event.
474                        if let Ok(mut term) = tab.terminal.try_write() {
475                            term.set_cell_dimensions(cell_width as u32, cell_height as u32);
476                            if let Err(e) =
477                                term.resize_with_pixels(new_cols, new_rows, width_px, height_px)
478                            {
479                                crate::debug_error!(
480                                    "TERMINAL",
481                                    "resize_with_pixels failed (close_tab): {e}"
482                                );
483                            }
484                        }
485                        tab.active_cache_mut().cells = None;
486                    }
487                    log::info!(
488                        "Tab bar visibility changed (position={:?}), resized remaining tabs to {}x{}",
489                        self.config.load().tabs.tab_bar_position,
490                        new_cols,
491                        new_rows
492                    );
493                }
494            }
495
496            self.focus_state.needs_redraw = true;
497            self.request_redraw();
498            is_last
499        } else {
500            true // No tabs, window should close
501        }
502    }
503
504    /// Switch to next tab
505    pub fn next_tab(&mut self) {
506        self.copy_mode.exit();
507        self.tab_manager.next_tab();
508        self.clear_and_invalidate();
509    }
510
511    /// Switch to previous tab
512    pub fn prev_tab(&mut self) {
513        self.copy_mode.exit();
514        self.tab_manager.prev_tab();
515        self.clear_and_invalidate();
516    }
517
518    /// Switch to tab by index (1-based)
519    pub fn switch_to_tab_index(&mut self, index: usize) {
520        self.copy_mode.exit();
521        self.tab_manager.switch_to_index(index);
522        self.clear_and_invalidate();
523    }
524
525    /// Move current tab left
526    pub fn move_tab_left(&mut self) {
527        self.tab_manager.move_active_tab_left();
528        self.focus_state.needs_redraw = true;
529        self.request_redraw();
530    }
531
532    /// Move current tab right
533    pub fn move_tab_right(&mut self) {
534        self.tab_manager.move_active_tab_right();
535        self.focus_state.needs_redraw = true;
536        self.request_redraw();
537    }
538}