Skip to main content

par_term/app/tab_ops/
pane_transfer.rs

1//! Pane/tab promotion: promote pane to tab, demote tab to pane.
2
3use std::sync::Arc;
4
5use par_term_config::TabId;
6
7use super::super::window_state::WindowState;
8use crate::pane::{ExtractResult, PaneId, SplitDirection};
9
10/// State machine for the multi-step demote (tab → pane) pick mode.
11#[derive(Default)]
12pub(crate) enum PaneTransferState {
13    #[default]
14    Idle,
15    DemotePickTab {
16        source_tab_id: TabId,
17    },
18    DemotePickPane {
19        source_tab_id: TabId,
20        target_tab_id: TabId,
21    },
22    DemoteChooseDirection {
23        source_tab_id: TabId,
24        target_tab_id: TabId,
25        target_pane_id: PaneId,
26    },
27}
28
29impl PaneTransferState {
30    pub fn is_active(&self) -> bool {
31        !matches!(self, PaneTransferState::Idle)
32    }
33}
34
35impl WindowState {
36    /// Promote the focused pane in the current tab to its own tab.
37    pub fn promote_pane_to_tab(&mut self) {
38        let source_tab_id = match self.tab_manager.active_tab_id() {
39            Some(id) => id,
40            None => return,
41        };
42
43        let focused_pane_id = match self
44            .tab_manager
45            .active_tab()
46            .and_then(|t| t.focused_pane_id())
47        {
48            Some(id) => id,
49            None => return,
50        };
51
52        // Extract the pane from the source tab's tree
53        let pane = match self.tab_manager.get_tab_mut(source_tab_id) {
54            Some(tab) => {
55                let pm = match tab.pane_manager_mut() {
56                    Some(pm) => pm,
57                    None => return,
58                };
59                match pm.extract_pane(focused_pane_id) {
60                    ExtractResult::Extracted { pane, remaining } => {
61                        // Put the remaining tree back into the source tab
62                        if let Some(remaining_node) = remaining
63                            && let Some(tab) = self.tab_manager.get_tab_mut(source_tab_id)
64                            && let Some(pm) = tab.pane_manager_mut()
65                        {
66                            pm.set_root(remaining_node);
67                        }
68                        pane
69                    }
70                    ExtractResult::OnlyPane(pane) => pane,
71                    ExtractResult::NotFound => return,
72                }
73            }
74            None => return,
75        };
76
77        // Check if source tab is now empty (pane_manager root is None)
78        let source_is_empty = self
79            .tab_manager
80            .get_tab(source_tab_id)
81            .is_none_or(|t| t.pane_count() == 0);
82
83        // Create and insert the new tab after the source
84        let new_tab_id = self.tab_manager.new_tab_from_pane(
85            pane,
86            &self.config.load(),
87            Arc::clone(&self.runtime),
88            if source_is_empty {
89                None
90            } else {
91                Some(source_tab_id)
92            },
93        );
94
95        // If source was empty, remove it without killing the terminal
96        // (the promoted pane still holds an Arc to the same terminal)
97        if source_is_empty {
98            if let Some(source_tab) = self.tab_manager.get_tab_mut(source_tab_id) {
99                source_tab.shutdown_fast = true;
100                source_tab.stop_refresh_task();
101            }
102            let _ = self.tab_manager.remove_tab(source_tab_id);
103        }
104
105        // Start refresh tasks for the new tab
106        if let Some(window) = &self.window
107            && let Some(tab) = self.tab_manager.get_tab_mut(new_tab_id)
108        {
109            tab.start_refresh_task(
110                Arc::clone(&self.runtime),
111                Arc::clone(window),
112                self.config.load().rendering.max_fps,
113                self.config.load().power.inactive_tab_fps,
114            );
115            tab.start_pane_refresh_tasks(
116                Arc::clone(&self.runtime),
117                Arc::clone(window),
118                self.config.load().rendering.max_fps,
119                self.config.load().power.inactive_tab_fps,
120            );
121        }
122
123        // Resize the new tab's terminal to match renderer dimensions
124        if let Some(renderer) = &self.renderer
125            && let Some(tab) = self.tab_manager.get_tab_mut(new_tab_id)
126        {
127            let (cols, rows) = renderer.grid_size();
128            let cell_width = renderer.cell_width();
129            let cell_height = renderer.cell_height();
130            let width_px = (cols as f32 * cell_width) as usize;
131            let height_px = (rows as f32 * cell_height) as usize;
132            if let Ok(mut term) = tab.terminal.try_write() {
133                term.set_cell_dimensions(cell_width as u32, cell_height as u32);
134                let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
135            }
136        }
137
138        // Clear renderer and request redraw
139        if let Some(renderer) = &mut self.renderer {
140            renderer.clear_all_cells();
141        }
142        self.focus_state.needs_redraw = true;
143        self.request_redraw();
144
145        crate::debug_info!(
146            "PANE_PROMOTE",
147            "Promoted pane {} to new tab {}",
148            focused_pane_id,
149            new_tab_id
150        );
151    }
152
153    /// Start the demote (tab → pane) pick mode.
154    pub fn start_demote_tab(&mut self) {
155        if self.tab_manager.tab_count() < 2 {
156            log::warn!("Cannot demote tab: need at least 2 tabs");
157            return;
158        }
159        if let Some(tab_id) = self.tab_manager.active_tab_id() {
160            self.pane_transfer_state = PaneTransferState::DemotePickTab {
161                source_tab_id: tab_id,
162            };
163            self.show_toast("Demote: Click a tab to merge into");
164            self.focus_state.needs_redraw = true;
165            self.request_redraw();
166            crate::debug_info!("TAB_DEMOTE", "Started demote pick mode for tab {}", tab_id);
167        }
168    }
169
170    /// Cancel the demote pick mode.
171    pub fn cancel_pane_transfer(&mut self) {
172        self.pane_transfer_state = PaneTransferState::Idle;
173        self.show_toast("Demote cancelled");
174        self.focus_state.needs_redraw = true;
175        self.request_redraw();
176    }
177
178    /// Execute the demote: merge source tab's pane tree into target tab.
179    pub(crate) fn execute_demote(
180        &mut self,
181        source_tab_id: TabId,
182        target_tab_id: TabId,
183        target_pane_id: PaneId,
184        direction: SplitDirection,
185    ) {
186        // Check max_panes on target tab
187        let config = self.config.load();
188        if config.panes.max_panes > 0 {
189            let target_count = self
190                .tab_manager
191                .get_tab(target_tab_id)
192                .map(|t| t.pane_count())
193                .unwrap_or(0);
194            let source_count = self
195                .tab_manager
196                .get_tab(source_tab_id)
197                .map(|t| t.pane_count())
198                .unwrap_or(0);
199            if target_count + source_count > config.panes.max_panes {
200                log::warn!(
201                    "Cannot demote: would exceed max_panes ({})",
202                    config.panes.max_panes
203                );
204                self.cancel_pane_transfer();
205                return;
206            }
207        }
208        drop(config);
209
210        // Captured before `take_root` nulls it, so a failed insert can put the
211        // source tab back the way the user left it rather than merely intact.
212        let source_focus = self
213            .tab_manager
214            .get_tab(source_tab_id)
215            .and_then(|tab| tab.focused_pane_id());
216
217        // Extract the source tab's entire pane tree
218        let source_tree = match self.tab_manager.get_tab_mut(source_tab_id) {
219            Some(tab) => match tab.pane_manager_mut() {
220                Some(pm) => pm.take_root(),
221                None => {
222                    self.cancel_pane_transfer();
223                    return;
224                }
225            },
226            None => {
227                self.cancel_pane_transfer();
228                return;
229            }
230        };
231
232        let source_tree = match source_tree {
233            Some(tree) => tree,
234            None => {
235                self.cancel_pane_transfer();
236                return;
237            }
238        };
239
240        // Insert the source tree into the target tab.
241        //
242        // The remap is discarded because no id that survives a *successful*
243        // insert needs translating: `target_pane_id` names a pane of the target
244        // tree, which is never renumbered, and the source tab is removed below.
245        // `source_focus` is a source-side id held across the call, but it is
246        // read only on the failure path, where the subtree comes back carrying
247        // the ids it arrived with — so it needs no translation either. A future
248        // consumer that carries a subtree-side id into the success path must
249        // translate it through the map instead of reusing the pre-move value.
250        //
251        // The tree is already detached from the source tab by this point, so on
252        // failure it must be caught and put back: it is the only reference to
253        // those panes, and dropping it kills every terminal in the source tab.
254        let insert_result = match self.tab_manager.get_tab_mut(target_tab_id) {
255            Some(tab) => match tab.pane_manager_mut() {
256                Some(pm) => pm.insert_subtree_at(target_pane_id, source_tree, direction, 0.5),
257                None => Err(source_tree),
258            },
259            None => Err(source_tree),
260        };
261
262        if let Err(source_tree) = insert_result {
263            self.restore_demoted_tree(source_tab_id, target_pane_id, source_tree, source_focus);
264            return;
265        }
266
267        // Close the source tab without killing terminals (panes are now in target)
268        // Setting shutdown_fast prevents Tab::Drop from killing self.terminal,
269        // which is shared with the primary pane that was transplanted into the target.
270        if let Some(source_tab) = self.tab_manager.get_tab_mut(source_tab_id) {
271            source_tab.shutdown_fast = true;
272            // Also stop the tab-level refresh task so it doesn't poll the
273            // now-empty PaneManager after the tab is removed from the list.
274            source_tab.stop_refresh_task();
275        }
276        let _ = self.tab_manager.remove_tab(source_tab_id);
277
278        // Start refresh tasks for all panes in the target tab
279        // (this also updates is_active on transplanted panes)
280        if let Some(window) = &self.window
281            && let Some(tab) = self.tab_manager.get_tab_mut(target_tab_id)
282        {
283            tab.start_pane_refresh_tasks(
284                Arc::clone(&self.runtime),
285                Arc::clone(window),
286                self.config.load().rendering.max_fps,
287                self.config.load().power.inactive_tab_fps,
288            );
289        }
290
291        self.pane_transfer_state = PaneTransferState::Idle;
292
293        if let Some(renderer) = &mut self.renderer {
294            renderer.clear_all_cells();
295        }
296        self.focus_state.needs_redraw = true;
297        self.request_redraw();
298
299        crate::debug_info!(
300            "TAB_DEMOTE",
301            "Demoted tab {} into tab {} at pane {}",
302            source_tab_id,
303            target_tab_id,
304            target_pane_id
305        );
306    }
307
308    /// Put a demoted pane tree back into its source tab after a failed insert.
309    ///
310    /// `execute_demote` detaches the source tree before it can know the insert
311    /// will succeed, so `source_tree` is by then the only reference to those
312    /// panes. Letting it drop closes every one of them and kills their PTYs —
313    /// the user's whole tab, without a single error on screen.
314    ///
315    /// The target pane can genuinely be gone by now: it is picked by a click,
316    /// and the direction overlay it opens takes further frames to answer,
317    /// during which that pane's shell can exit, another binding can close it,
318    /// or a tmux layout update can rebuild the tab's tree.
319    ///
320    /// Deliberately does not route through `cancel_pane_transfer`, which toasts
321    /// "Demote cancelled" — the user did not cancel anything, and a message
322    /// saying they did would hide the failure this exists to report.
323    fn restore_demoted_tree(
324        &mut self,
325        source_tab_id: TabId,
326        target_pane_id: PaneId,
327        source_tree: crate::pane::PaneNode,
328        source_focus: Option<PaneId>,
329    ) {
330        let pane_count = source_tree.pane_count();
331
332        let restored = if let Some(pm) = self
333            .tab_manager
334            .get_tab_mut(source_tab_id)
335            .and_then(|tab| tab.pane_manager_mut())
336        {
337            pm.set_root(source_tree);
338            // `set_root` leaves focus alone, and `take_root` nulled it.
339            if let Some(id) = source_focus {
340                pm.focus_pane(id);
341            }
342            true
343        } else {
344            false
345        };
346
347        self.pane_transfer_state = PaneTransferState::Idle;
348
349        if restored {
350            log::warn!(
351                "Demote aborted: target pane {} no longer exists; \
352                 returned {} pane(s) to tab {}",
353                target_pane_id,
354                pane_count,
355                source_tab_id
356            );
357            self.show_toast("Demote failed: target pane is gone — tab left unchanged");
358        } else {
359            // Unreachable in practice: the tree was taken out of this same tab
360            // a few statements ago and nothing between can remove it. Reported
361            // rather than swallowed, because reaching it means the panes really
362            // were lost. Re-homing them into a fresh tab was considered and is
363            // not possible here: `new_tab_from_pane` takes a single `Pane`, and
364            // this is a whole `PaneNode` tree.
365            log::error!(
366                "Demote aborted and tab {} vanished; {} pane(s) could not be restored",
367                source_tab_id,
368                pane_count
369            );
370            self.show_toast("Demote failed: source tab is gone");
371        }
372
373        if let Some(renderer) = &mut self.renderer {
374            renderer.clear_all_cells();
375        }
376        self.focus_state.needs_redraw = true;
377        self.request_redraw();
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::config::Config;
385    use crate::pane::{Pane, PaneNode};
386    use crate::tab::Tab;
387    use par_term_terminal::TerminalManager;
388    use std::sync::atomic::AtomicBool;
389    use tokio::sync::RwLock;
390
391    fn test_runtime() -> Arc<tokio::runtime::Runtime> {
392        Arc::new(
393            tokio::runtime::Builder::new_current_thread()
394                .enable_all()
395                .build()
396                .expect("build test runtime"),
397        )
398    }
399
400    /// A pane whose terminal has no shell spawned, so it runs without a PTY on
401    /// every supported platform. `working_directory` carries the marker.
402    fn stub_pane(id: PaneId, marker: &str) -> Pane {
403        let terminal = TerminalManager::new_with_scrollback(80, 24, 100)
404            .expect("stub terminal creation without a shell");
405        Pane::new_wrapping_terminal(
406            id,
407            Arc::new(RwLock::new(terminal)),
408            Some(marker.to_string()),
409            Arc::new(AtomicBool::new(false)),
410        )
411    }
412
413    /// A `WindowState` holding two shell-less tabs: source tab 1 with a
414    /// two-pane tree, target tab 2 with its stub pane.
415    fn window_with_source_and_target() -> WindowState {
416        let mut state = WindowState::new(Config::default(), test_runtime());
417
418        let mut source = Tab::new_stub(1, 1);
419        let pm = source
420            .pane_manager_mut()
421            .expect("a stub tab always has a pane manager");
422        pm.set_root(PaneNode::split(
423            SplitDirection::Vertical,
424            0.5,
425            PaneNode::leaf(stub_pane(1, "/source/one")),
426            PaneNode::leaf(stub_pane(2, "/source/two")),
427        ));
428        pm.focus_pane(2);
429
430        state.tab_manager.push_tab_for_test(source);
431        state.tab_manager.push_tab_for_test(Tab::new_stub(2, 2));
432        state
433    }
434
435    #[test]
436    fn a_demote_onto_a_vanished_pane_does_not_destroy_the_source_tab() {
437        // `execute_demote` detaches the source tab's whole tree before it can
438        // know the insert will succeed. The target pane is picked by a click
439        // and confirmed frames later through the direction overlay, so it can
440        // be gone by the time this runs — its shell exits, a binding closes it,
441        // a tmux layout update rebuilds the tree. If the tree is dropped on
442        // that path, every terminal in the source tab dies silently.
443        let mut state = window_with_source_and_target();
444
445        // Held across the call, so terminal liveness is observable regardless
446        // of where the tree ends up.
447        let kept_terminals: Vec<Arc<RwLock<par_term_terminal::TerminalManager>>> = state
448            .tab_manager
449            .get_tab(1)
450            .and_then(|t| t.pane_manager())
451            .expect("source tab has a pane manager")
452            .all_panes()
453            .iter()
454            .map(|p| Arc::clone(&p.terminal))
455            .collect();
456        let refs_before: Vec<usize> = kept_terminals.iter().map(Arc::strong_count).collect();
457
458        const GONE: PaneId = 999;
459        state.execute_demote(1, 2, GONE, SplitDirection::Vertical);
460
461        let refs_after: Vec<usize> = kept_terminals.iter().map(Arc::strong_count).collect();
462        assert_eq!(
463            refs_after, refs_before,
464            "every source terminal must still be held by its pane, not dropped"
465        );
466
467        let source = state
468            .tab_manager
469            .get_tab(1)
470            .expect("the source tab must not be closed by a failed demote");
471        assert_eq!(
472            source.pane_count(),
473            2,
474            "both source panes must still be in the source tab"
475        );
476
477        let pm = source.pane_manager().expect("source pane manager");
478        for (id, marker) in [(1, "/source/one"), (2, "/source/two")] {
479            assert_eq!(
480                pm.get_pane(id)
481                    .and_then(|p| p.working_directory.clone())
482                    .as_deref(),
483                Some(marker),
484                "pane {id} must be the one that was there before the demote"
485            );
486        }
487        assert_eq!(
488            pm.focused_pane_id(),
489            Some(2),
490            "the tab must come back focused where the user left it"
491        );
492
493        assert_eq!(
494            state.tab_manager.get_tab(2).map(|t| t.pane_count()),
495            Some(1),
496            "the target tab must be left exactly as it was"
497        );
498
499        // A silent no-op is better than data loss but still wrong: the user
500        // asked for a merge and did not get one.
501        let toast = state
502            .overlay_state
503            .toast_message
504            .as_deref()
505            .expect("a failed demote must tell the user");
506        assert!(
507            toast.contains("Demote failed"),
508            "the toast must report a failure, got {toast:?}"
509        );
510        assert!(
511            !toast.contains("cancelled"),
512            "the user did not cancel anything, got {toast:?}"
513        );
514        assert!(
515            !state.pane_transfer_state.is_active(),
516            "the pick-mode state machine must not be left mid-demote"
517        );
518    }
519}