1use std::sync::Arc;
4
5use par_term_config::TabId;
6
7use super::super::window_state::WindowState;
8use crate::pane::{ExtractResult, PaneId, SplitDirection};
9
10#[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 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 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 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 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 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_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 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 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 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 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 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 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 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 let source_focus = self
213 .tab_manager
214 .get_tab(source_tab_id)
215 .and_then(|tab| tab.focused_pane_id());
216
217 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 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 if let Some(source_tab) = self.tab_manager.get_tab_mut(source_tab_id) {
271 source_tab.shutdown_fast = true;
272 source_tab.stop_refresh_task();
275 }
276 let _ = self.tab_manager.remove_tab(source_tab_id);
277
278 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 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 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 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 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 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 let mut state = window_with_source_and_target();
444
445 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 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}