1use std::sync::Arc;
6
7use crate::profile::{ProfileId, ProfileManager, storage as profile_storage};
8
9use super::window_state::WindowState;
10
11pub(crate) struct ClosedTabInfo {
13 pub cwd: Option<String>,
14 pub title: String,
15 pub has_default_title: bool,
16 pub index: usize,
17 pub closed_at: std::time::Instant,
18 pub pane_layout: Option<crate::session::SessionPaneNode>,
19 pub custom_color: Option<[u8; 3]>,
20 pub hidden_tab: Option<crate::tab::Tab>,
24}
25
26impl WindowState {
27 pub fn new_tab_or_show_profiles(&mut self) {
29 if self.config.new_tab_shortcut_shows_profiles && !self.profile_manager.is_empty() {
30 self.tab_bar_ui.show_new_tab_profile_menu = !self.tab_bar_ui.show_new_tab_profile_menu;
31 if let Some(window) = &self.window {
32 window.request_redraw();
33 }
34 log::info!("Toggled new-tab profile menu via shortcut");
35 } else {
36 self.new_tab();
37 log::info!("New tab created");
38 }
39 }
40
41 pub fn new_tab(&mut self) {
43 if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
45 log::warn!(
46 "Cannot create new tab: max_tabs limit ({}) reached",
47 self.config.max_tabs
48 );
49 return;
50 }
51
52 let old_tab_count = self.tab_manager.tab_count();
54
55 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
58
59 match self.tab_manager.new_tab(
60 &self.config,
61 Arc::clone(&self.runtime),
62 self.config.tab_inherit_cwd,
63 grid_size,
64 ) {
65 Ok(tab_id) => {
66 let new_tab_count = self.tab_manager.tab_count();
68 let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
69 let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
70 let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
71 let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
72
73 if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
75 || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
76 && let Some(renderer) = &mut self.renderer
77 && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
78 self.config.tab_bar_position,
79 renderer,
80 new_tab_bar_height,
81 new_tab_bar_width,
82 )
83 {
84 let cell_width = renderer.cell_width();
85 let cell_height = renderer.cell_height();
86 let width_px = (new_cols as f32 * cell_width) as usize;
87 let height_px = (new_rows as f32 * cell_height) as usize;
88
89 for tab in self.tab_manager.tabs_mut() {
91 if tab.id != tab_id {
92 if let Ok(mut term) = tab.terminal.try_lock() {
93 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
94 let _ = term
95 .resize_with_pixels(new_cols, new_rows, width_px, height_px);
96 }
97 tab.cache.cells = None;
98 }
99 }
100 log::info!(
101 "Tab bar appeared (position={:?}), resized existing tabs to {}x{}",
102 self.config.tab_bar_position,
103 new_cols,
104 new_rows
105 );
106 }
107
108 if let Some(window) = &self.window
110 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
111 {
112 tab.start_refresh_task(
113 Arc::clone(&self.runtime),
114 Arc::clone(window),
115 self.config.max_fps,
116 );
117
118 if let Some(renderer) = &self.renderer
121 && let Ok(mut term) = tab.terminal.try_lock()
122 {
123 let (cols, rows) = renderer.grid_size();
124 let cell_width = renderer.cell_width();
125 let cell_height = renderer.cell_height();
126 let width_px = (cols as f32 * cell_width) as usize;
127 let height_px = (rows as f32 * cell_height) as usize;
128
129 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
131
132 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
134 log::info!(
135 "Resized new tab {} terminal to {}x{} ({}x{} px)",
136 tab_id,
137 cols,
138 rows,
139 width_px,
140 height_px
141 );
142 }
143 }
144
145 self.play_alert_sound(crate::config::AlertEvent::NewTab);
147
148 self.needs_redraw = true;
149 self.request_redraw();
150 }
151 Err(e) => {
152 log::error!("Failed to create new tab: {}", e);
153 }
154 }
155 }
156
157 pub fn close_current_tab(&mut self) -> bool {
160 if self.config.confirm_close_running_jobs
162 && let Some(command_name) = self.check_current_tab_running_job()
163 && let Some(tab) = self.tab_manager.active_tab()
164 {
165 let tab_id = tab.id;
166 let tab_title = if tab.title.is_empty() {
167 "Terminal".to_string()
168 } else {
169 tab.title.clone()
170 };
171 self.close_confirmation_ui
172 .show_for_tab(tab_id, &tab_title, &command_name);
173 self.needs_redraw = true;
174 self.request_redraw();
175 return false; }
177
178 self.close_current_tab_immediately()
179 }
180
181 pub fn close_current_tab_immediately(&mut self) -> bool {
184 if let Some(tab_id) = self.tab_manager.active_tab_id() {
185 let old_tab_count = self.tab_manager.tab_count();
187 let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
188 let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
189
190 let is_last_tab = self.tab_manager.tab_count() <= 1;
191 let preserve_shell = self.config.session_undo_preserve_shell
192 && self.config.session_undo_timeout_secs > 0;
193
194 let is_last = if preserve_shell {
196 if let Some(tab) = self.tab_manager.get_tab(tab_id) {
198 let cwd = tab.get_cwd();
199 let title = tab.title.clone();
200 let has_default_title = tab.has_default_title;
201 let custom_color = tab.custom_color;
202 let index = self.tab_manager.active_tab_index().unwrap_or(0);
203
204 if let Some((mut hidden_tab, is_empty)) = self.tab_manager.remove_tab(tab_id) {
205 hidden_tab.stop_refresh_task();
207
208 let info = ClosedTabInfo {
209 cwd,
210 title,
211 has_default_title,
212 index,
213 closed_at: std::time::Instant::now(),
214 pane_layout: None, custom_color,
216 hidden_tab: Some(hidden_tab),
217 };
218 self.closed_tabs.push_front(info);
219 while self.closed_tabs.len() > self.config.session_undo_max_entries {
220 self.closed_tabs.pop_back();
221 }
222 is_empty
223 } else {
224 self.tab_manager.close_tab(tab_id)
226 }
227 } else {
228 self.tab_manager.close_tab(tab_id)
229 }
230 } else {
231 if self.config.session_undo_timeout_secs > 0
233 && let Some(tab) = self.tab_manager.get_tab(tab_id)
234 {
235 let info = ClosedTabInfo {
236 cwd: tab.get_cwd(),
237 title: tab.title.clone(),
238 has_default_title: tab.has_default_title,
239 index: self.tab_manager.active_tab_index().unwrap_or(0),
240 closed_at: std::time::Instant::now(),
241 pane_layout: tab
242 .pane_manager
243 .as_ref()
244 .and_then(|pm| pm.root())
245 .map(crate::session::capture::capture_pane_node),
246 custom_color: tab.custom_color,
247 hidden_tab: None,
248 };
249 self.closed_tabs.push_front(info);
250 while self.closed_tabs.len() > self.config.session_undo_max_entries {
251 self.closed_tabs.pop_back();
252 }
253 }
254
255 self.tab_manager.close_tab(tab_id)
256 };
257
258 self.play_alert_sound(crate::config::AlertEvent::TabClose);
260
261 if !is_last_tab {
263 let key_hint = self
264 .config
265 .keybindings
266 .iter()
267 .find(|kb| kb.action == "reopen_closed_tab")
268 .map(|kb| kb.key.clone())
269 .unwrap_or_else(|| "keybinding".to_string());
270 let timeout = self.config.session_undo_timeout_secs;
271 if timeout > 0 {
272 self.show_toast(format!(
273 "Tab closed. Press {} to undo ({timeout}s)",
274 key_hint
275 ));
276 }
277 }
278
279 if !is_last {
281 let new_tab_count = self.tab_manager.tab_count();
282 let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
283 let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
284
285 if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
286 || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
287 && let Some(renderer) = &mut self.renderer
288 && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
289 self.config.tab_bar_position,
290 renderer,
291 new_tab_bar_height,
292 new_tab_bar_width,
293 )
294 {
295 let cell_width = renderer.cell_width();
296 let cell_height = renderer.cell_height();
297 let width_px = (new_cols as f32 * cell_width) as usize;
298 let height_px = (new_rows as f32 * cell_height) as usize;
299
300 for tab in self.tab_manager.tabs_mut() {
302 if let Ok(mut term) = tab.terminal.try_lock() {
303 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
304 let _ =
305 term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
306 }
307 tab.cache.cells = None;
308 }
309 log::info!(
310 "Tab bar visibility changed (position={:?}), resized remaining tabs to {}x{}",
311 self.config.tab_bar_position,
312 new_cols,
313 new_rows
314 );
315 }
316 }
317
318 self.needs_redraw = true;
319 self.request_redraw();
320 is_last
321 } else {
322 true }
324 }
325
326 pub fn reopen_closed_tab(&mut self) {
328 if self.config.session_undo_timeout_secs > 0 {
330 let timeout =
331 std::time::Duration::from_secs(self.config.session_undo_timeout_secs as u64);
332 let now = std::time::Instant::now();
333 self.closed_tabs
334 .retain(|info| now.duration_since(info.closed_at) < timeout);
335 }
336
337 let info = match self.closed_tabs.pop_front() {
338 Some(info) => info,
339 None => {
340 self.show_toast("No recently closed tabs");
341 return;
342 }
343 };
344
345 if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
347 log::warn!(
348 "Cannot reopen tab: max_tabs limit ({}) reached",
349 self.config.max_tabs
350 );
351 self.show_toast("Cannot reopen tab: max tabs limit reached");
352 self.closed_tabs.push_front(info);
354 return;
355 }
356
357 let old_tab_count = self.tab_manager.tab_count();
359
360 if let Some(hidden_tab) = info.hidden_tab {
361 let tab_id = hidden_tab.id;
363 self.tab_manager.insert_tab_at(hidden_tab, info.index);
364
365 self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);
367
368 if let Some(window) = &self.window
370 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
371 {
372 tab.start_refresh_task(
373 Arc::clone(&self.runtime),
374 Arc::clone(window),
375 self.config.max_fps,
376 );
377
378 tab.cache.cells = None;
380
381 if let Some(renderer) = &self.renderer
382 && let Ok(mut term) = tab.terminal.try_lock()
383 {
384 let (cols, rows) = renderer.grid_size();
385 let cell_width = renderer.cell_width();
386 let cell_height = renderer.cell_height();
387 let width_px = (cols as f32 * cell_width) as usize;
388 let height_px = (rows as f32 * cell_height) as usize;
389 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
390 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
391 }
392 }
393
394 self.play_alert_sound(crate::config::AlertEvent::NewTab);
395 self.show_toast("Tab restored (session preserved)");
396 self.needs_redraw = true;
397 self.request_redraw();
398 } else {
399 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
401
402 match self.tab_manager.new_tab_with_cwd(
403 &self.config,
404 Arc::clone(&self.runtime),
405 info.cwd,
406 grid_size,
407 ) {
408 Ok(tab_id) => {
409 self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);
411
412 if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
414 if !info.has_default_title {
415 tab.title = info.title;
416 tab.has_default_title = false;
417 }
418 tab.custom_color = info.custom_color;
419 }
420
421 self.tab_manager.move_tab_to_index(tab_id, info.index);
423
424 if let Some(window) = &self.window
426 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
427 {
428 tab.start_refresh_task(
429 Arc::clone(&self.runtime),
430 Arc::clone(window),
431 self.config.max_fps,
432 );
433
434 if let Some(renderer) = &self.renderer
435 && let Ok(mut term) = tab.terminal.try_lock()
436 {
437 let (cols, rows) = renderer.grid_size();
438 let cell_width = renderer.cell_width();
439 let cell_height = renderer.cell_height();
440 let width_px = (cols as f32 * cell_width) as usize;
441 let height_px = (rows as f32 * cell_height) as usize;
442 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
443 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
444 }
445 }
446
447 if let Some(pane_layout) = &info.pane_layout
449 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
450 {
451 tab.restore_pane_layout(
452 pane_layout,
453 &self.config,
454 Arc::clone(&self.runtime),
455 );
456 }
457
458 self.play_alert_sound(crate::config::AlertEvent::NewTab);
459 self.show_toast("Tab restored");
460 self.needs_redraw = true;
461 self.request_redraw();
462 }
463 Err(e) => {
464 log::error!("Failed to reopen closed tab: {}", e);
465 self.show_toast("Failed to reopen tab");
466 }
467 }
468 }
469 }
470
471 fn handle_tab_bar_resize_after_add(
474 &mut self,
475 old_tab_count: usize,
476 new_tab_id: crate::tab::TabId,
477 ) {
478 let new_tab_count = self.tab_manager.tab_count();
479 let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
480 let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
481 let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
482 let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
483
484 if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
485 || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
486 && let Some(renderer) = &mut self.renderer
487 && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
488 self.config.tab_bar_position,
489 renderer,
490 new_tab_bar_height,
491 new_tab_bar_width,
492 )
493 {
494 let cell_width = renderer.cell_width();
495 let cell_height = renderer.cell_height();
496 let width_px = (new_cols as f32 * cell_width) as usize;
497 let height_px = (new_rows as f32 * cell_height) as usize;
498
499 for tab in self.tab_manager.tabs_mut() {
500 if tab.id != new_tab_id {
501 if let Ok(mut term) = tab.terminal.try_lock() {
502 term.set_cell_dimensions(cell_width as u32, cell_height as u32);
503 let _ = term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
504 }
505 tab.cache.cells = None;
506 }
507 }
508 }
509 }
510
511 pub fn next_tab(&mut self) {
513 self.copy_mode.exit();
514 self.tab_manager.next_tab();
515 self.clear_and_invalidate();
516 }
517
518 pub fn prev_tab(&mut self) {
520 self.copy_mode.exit();
521 self.tab_manager.prev_tab();
522 self.clear_and_invalidate();
523 }
524
525 pub fn switch_to_tab_index(&mut self, index: usize) {
527 self.copy_mode.exit();
528 self.tab_manager.switch_to_index(index);
529 self.clear_and_invalidate();
530 }
531
532 pub fn move_tab_left(&mut self) {
534 self.tab_manager.move_active_tab_left();
535 self.needs_redraw = true;
536 self.request_redraw();
537 }
538
539 pub fn move_tab_right(&mut self) {
541 self.tab_manager.move_active_tab_right();
542 self.needs_redraw = true;
543 self.request_redraw();
544 }
545
546 pub fn duplicate_tab(&mut self) {
548 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
550
551 match self.tab_manager.duplicate_active_tab(
552 &self.config,
553 Arc::clone(&self.runtime),
554 grid_size,
555 ) {
556 Ok(Some(tab_id)) => {
557 if let Some(window) = &self.window
559 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
560 {
561 tab.start_refresh_task(
562 Arc::clone(&self.runtime),
563 Arc::clone(window),
564 self.config.max_fps,
565 );
566 }
567 self.needs_redraw = true;
568 self.request_redraw();
569 }
570 Ok(None) => {
571 log::debug!("No active tab to duplicate");
572 }
573 Err(e) => {
574 log::error!("Failed to duplicate tab: {}", e);
575 }
576 }
577 }
578
579 pub fn duplicate_tab_by_id(&mut self, source_tab_id: crate::tab::TabId) {
581 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
582
583 match self.tab_manager.duplicate_tab_by_id(
584 source_tab_id,
585 &self.config,
586 Arc::clone(&self.runtime),
587 grid_size,
588 ) {
589 Ok(Some(tab_id)) => {
590 if let Some(window) = &self.window
591 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
592 {
593 tab.start_refresh_task(
594 Arc::clone(&self.runtime),
595 Arc::clone(window),
596 self.config.max_fps,
597 );
598 }
599 self.needs_redraw = true;
600 self.request_redraw();
601 }
602 Ok(None) => {
603 log::debug!("Tab {} not found for duplication", source_tab_id);
604 }
605 Err(e) => {
606 log::error!("Failed to duplicate tab {}: {}", source_tab_id, e);
607 }
608 }
609 }
610
611 pub fn has_multiple_tabs(&self) -> bool {
613 self.tab_manager.has_multiple_tabs()
614 }
615
616 #[allow(dead_code)]
618 pub fn active_terminal(
619 &self,
620 ) -> Option<&Arc<tokio::sync::Mutex<crate::terminal::TerminalManager>>> {
621 self.tab_manager.active_tab().map(|tab| &tab.terminal)
622 }
623
624 pub fn split_pane_horizontal(&mut self) {
630 if self.is_tmux_connected() && self.split_pane_via_tmux(false) {
632 crate::debug_info!("TMUX", "Sent horizontal split command to tmux");
633 return;
634 }
635 let is_tmux_connected = self.is_tmux_connected();
639 let status_bar_height =
640 crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
641 let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
642
643 let bounds_info = self.renderer.as_ref().map(|r| {
645 let size = r.size();
646 let padding = r.window_padding();
647 let content_offset_y = r.content_offset_y();
648 let cell_width = r.cell_width();
649 let cell_height = r.cell_height();
650 let scale = r.scale_factor();
651 (
652 size,
653 padding,
654 content_offset_y,
655 cell_width,
656 cell_height,
657 scale,
658 )
659 });
660
661 let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
662
663 if let Some(tab) = self.tab_manager.active_tab_mut() {
664 if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
666 bounds_info
667 {
668 let effective_padding = if self.config.hide_window_padding_on_split {
670 0.0
671 } else {
672 padding
673 };
674 let physical_status_bar_height =
676 (status_bar_height + custom_status_bar_height) * scale;
677 let content_width = size.width as f32 - effective_padding * 2.0;
678 let content_height = size.height as f32
679 - content_offset_y
680 - effective_padding
681 - physical_status_bar_height;
682 let bounds = crate::pane::PaneBounds::new(
683 effective_padding,
684 content_offset_y,
685 content_width,
686 content_height,
687 );
688 tab.set_pane_bounds(bounds, cell_width, cell_height);
689 }
690
691 match tab.split_horizontal(&self.config, Arc::clone(&self.runtime), dpi_scale) {
692 Ok(Some(pane_id)) => {
693 log::info!("Split pane horizontally, new pane {}", pane_id);
694 if let Some(renderer) = &mut self.renderer {
696 renderer.clear_all_cells();
697 }
698 tab.cache.cells = None;
700 self.needs_redraw = true;
701 self.request_redraw();
702 }
703 Ok(None) => {
704 log::info!(
705 "Horizontal split not yet functional (renderer integration pending)"
706 );
707 }
708 Err(e) => {
709 log::error!("Failed to split pane horizontally: {}", e);
710 }
711 }
712 }
713 }
714
715 pub fn split_pane_vertical(&mut self) {
717 if self.is_tmux_connected() && self.split_pane_via_tmux(true) {
719 crate::debug_info!("TMUX", "Sent vertical split command to tmux");
720 return;
721 }
722 let is_tmux_connected = self.is_tmux_connected();
726 let status_bar_height =
727 crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
728 let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
729
730 let bounds_info = self.renderer.as_ref().map(|r| {
732 let size = r.size();
733 let padding = r.window_padding();
734 let content_offset_y = r.content_offset_y();
735 let cell_width = r.cell_width();
736 let cell_height = r.cell_height();
737 let scale = r.scale_factor();
738 (
739 size,
740 padding,
741 content_offset_y,
742 cell_width,
743 cell_height,
744 scale,
745 )
746 });
747
748 let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
749
750 if let Some(tab) = self.tab_manager.active_tab_mut() {
751 if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
753 bounds_info
754 {
755 let effective_padding = if self.config.hide_window_padding_on_split {
757 0.0
758 } else {
759 padding
760 };
761 let physical_status_bar_height =
763 (status_bar_height + custom_status_bar_height) * scale;
764 let content_width = size.width as f32 - effective_padding * 2.0;
765 let content_height = size.height as f32
766 - content_offset_y
767 - effective_padding
768 - physical_status_bar_height;
769 let bounds = crate::pane::PaneBounds::new(
770 effective_padding,
771 content_offset_y,
772 content_width,
773 content_height,
774 );
775 tab.set_pane_bounds(bounds, cell_width, cell_height);
776 }
777
778 match tab.split_vertical(&self.config, Arc::clone(&self.runtime), dpi_scale) {
779 Ok(Some(pane_id)) => {
780 log::info!("Split pane vertically, new pane {}", pane_id);
781 if let Some(renderer) = &mut self.renderer {
783 renderer.clear_all_cells();
784 }
785 tab.cache.cells = None;
787 self.needs_redraw = true;
788 self.request_redraw();
789 }
790 Ok(None) => {
791 log::info!("Vertical split not yet functional (renderer integration pending)");
792 }
793 Err(e) => {
794 log::error!("Failed to split pane vertically: {}", e);
795 }
796 }
797 }
798 }
799
800 pub fn close_focused_pane(&mut self) -> bool {
805 if self.is_tmux_connected() && self.close_pane_via_tmux() {
807 crate::debug_info!("TMUX", "Sent kill-pane command to tmux");
808 return false;
810 }
811 if self.config.confirm_close_running_jobs
815 && let Some(command_name) = self.check_current_pane_running_job()
816 && let Some(tab) = self.tab_manager.active_tab()
817 && let Some(pane_id) = tab.focused_pane_id()
818 {
819 let tab_id = tab.id;
820 let tab_title = if tab.title.is_empty() {
821 "Terminal".to_string()
822 } else {
823 tab.title.clone()
824 };
825 self.close_confirmation_ui
826 .show_for_pane(tab_id, pane_id, &tab_title, &command_name);
827 self.needs_redraw = true;
828 self.request_redraw();
829 return false; }
831
832 self.close_focused_pane_immediately()
833 }
834
835 fn close_focused_pane_immediately(&mut self) -> bool {
838 if let Some(tab) = self.tab_manager.active_tab_mut()
839 && tab.has_multiple_panes()
840 {
841 let is_last_pane = tab.close_focused_pane();
842 if is_last_pane {
843 return self.close_current_tab_immediately();
845 }
846 self.needs_redraw = true;
847 self.request_redraw();
848 return false;
849 }
850 self.close_current_tab_immediately()
852 }
853
854 fn check_current_tab_running_job(&self) -> Option<String> {
858 let tab = self.tab_manager.active_tab()?;
859 let term = tab.terminal.try_lock().ok()?;
860 term.should_confirm_close(&self.config.jobs_to_ignore)
861 }
862
863 fn check_current_pane_running_job(&self) -> Option<String> {
867 let tab = self.tab_manager.active_tab()?;
868
869 if tab.has_multiple_panes() {
871 let pane_manager = tab.pane_manager()?;
872 let focused_id = pane_manager.focused_pane_id()?;
873 let pane = pane_manager.get_pane(focused_id)?;
874 let term = pane.terminal.try_lock().ok()?;
875 return term.should_confirm_close(&self.config.jobs_to_ignore);
876 }
877
878 let term = tab.terminal.try_lock().ok()?;
880 term.should_confirm_close(&self.config.jobs_to_ignore)
881 }
882
883 pub fn has_multiple_panes(&self) -> bool {
885 self.tab_manager
886 .active_tab()
887 .is_some_and(|tab| tab.has_multiple_panes())
888 }
889
890 pub fn navigate_pane(&mut self, direction: crate::pane::NavigationDirection) {
892 if let Some(tab) = self.tab_manager.active_tab_mut()
893 && tab.has_multiple_panes()
894 {
895 tab.navigate_pane(direction);
896 self.needs_redraw = true;
897 self.request_redraw();
898 }
899 }
900
901 pub fn resize_pane(&mut self, direction: crate::pane::NavigationDirection) {
905 use crate::pane::NavigationDirection;
906
907 const RESIZE_DELTA: f32 = 0.05;
909
910 let delta = match direction {
914 NavigationDirection::Right | NavigationDirection::Down => RESIZE_DELTA,
915 NavigationDirection::Left | NavigationDirection::Up => -RESIZE_DELTA,
916 };
917
918 if let Some(tab) = self.tab_manager.active_tab_mut()
919 && let Some(pm) = tab.pane_manager_mut()
920 && let Some(focused_id) = pm.focused_pane_id()
921 {
922 pm.resize_split(focused_id, delta);
923 self.needs_redraw = true;
924 self.request_redraw();
925 }
926 }
927
928 pub fn open_profile(&mut self, profile_id: ProfileId) {
934 log::debug!("open_profile called with id: {:?}", profile_id);
935
936 if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
938 log::warn!(
939 "Cannot open profile: max_tabs limit ({}) reached",
940 self.config.max_tabs
941 );
942 self.deliver_notification(
943 "Tab Limit Reached",
944 &format!(
945 "Cannot open profile: maximum of {} tabs already open",
946 self.config.max_tabs
947 ),
948 );
949 return;
950 }
951
952 let profile = match self.profile_manager.get(&profile_id) {
953 Some(p) => p.clone(),
954 None => {
955 log::error!("Profile not found: {:?}", profile_id);
956 return;
957 }
958 };
959 log::debug!("Found profile: {}", profile.name);
960
961 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
963
964 match self.tab_manager.new_tab_from_profile(
965 &self.config,
966 Arc::clone(&self.runtime),
967 &profile,
968 grid_size,
969 ) {
970 Ok(tab_id) => {
971 if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
973 tab.profile_icon = profile.icon.clone();
974 }
975
976 if let Some(window) = &self.window
978 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
979 {
980 tab.start_refresh_task(
981 Arc::clone(&self.runtime),
982 Arc::clone(window),
983 self.config.max_fps,
984 );
985
986 if let Some(renderer) = &self.renderer
988 && let Ok(mut term) = tab.terminal.try_lock()
989 {
990 let (cols, rows) = renderer.grid_size();
991 let size = renderer.size();
992 let width_px = size.width as usize;
993 let height_px = size.height as usize;
994
995 term.set_cell_dimensions(
996 renderer.cell_width() as u32,
997 renderer.cell_height() as u32,
998 );
999 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
1000 log::info!(
1001 "Opened profile '{}' in tab {} ({}x{} at {}x{} px)",
1002 profile.name,
1003 tab_id,
1004 cols,
1005 rows,
1006 width_px,
1007 height_px
1008 );
1009 }
1010 }
1011
1012 self.apply_profile_badge(&profile);
1014
1015 self.needs_redraw = true;
1016 self.request_redraw();
1017 }
1018 Err(e) => {
1019 log::error!("Failed to open profile '{}': {}", profile.name, e);
1020
1021 let error_msg = e.to_string();
1023 let (title, message) = if error_msg.contains("Unable to spawn")
1024 || error_msg.contains("No viable candidates")
1025 {
1026 let cmd = profile
1028 .command
1029 .as_deref()
1030 .unwrap_or("the configured command");
1031 (
1032 format!("Profile '{}' Failed", profile.name),
1033 format!(
1034 "Command '{}' not found. Check that it's installed and in your PATH.",
1035 cmd
1036 ),
1037 )
1038 } else if error_msg.contains("No such file or directory") {
1039 (
1040 format!("Profile '{}' Failed", profile.name),
1041 format!(
1042 "Working directory not found: {}",
1043 profile.working_directory.as_deref().unwrap_or("(unknown)")
1044 ),
1045 )
1046 } else {
1047 (
1048 format!("Profile '{}' Failed", profile.name),
1049 format!("Failed to start: {}", error_msg),
1050 )
1051 };
1052 self.deliver_notification(&title, &message);
1053 }
1054 }
1055 }
1056
1057 pub(crate) fn apply_profile_badge(&mut self, profile: &crate::profile::Profile) {
1062 {
1064 let mut vars = self.badge_state.variables_mut();
1065 vars.profile_name = profile.name.clone();
1066 }
1067
1068 self.badge_state.apply_profile_settings(profile);
1070
1071 if profile.badge_text.is_some() {
1072 crate::debug_info!(
1073 "PROFILE",
1074 "Applied profile badge settings: format='{}', color={:?}, alpha={}",
1075 profile.badge_text.as_deref().unwrap_or(""),
1076 profile.badge_color,
1077 profile.badge_color_alpha.unwrap_or(0.0)
1078 );
1079 }
1080
1081 self.badge_state.mark_dirty();
1083 }
1084
1085 pub fn toggle_profile_drawer(&mut self) {
1087 self.profile_drawer_ui.toggle();
1088 self.needs_redraw = true;
1089 self.request_redraw();
1090 }
1091
1092 pub fn save_profiles(&self) {
1094 if let Err(e) = profile_storage::save_profiles(&self.profile_manager) {
1095 log::error!("Failed to save profiles: {}", e);
1096 }
1097 }
1098
1099 pub fn apply_profile_changes(&mut self, profiles: Vec<crate::profile::Profile>) {
1101 self.profile_manager = ProfileManager::from_profiles(profiles);
1102 self.save_profiles();
1103 self.profiles_menu_needs_update = true;
1105 }
1106
1107 pub fn check_auto_profile_switch(&mut self) -> bool {
1115 if self.profile_manager.is_empty() {
1116 return false;
1117 }
1118
1119 let mut changed = false;
1120
1121 changed |= self.check_auto_hostname_switch();
1123
1124 if !changed {
1126 changed |= self.check_ssh_command_switch();
1127 }
1128
1129 changed |= self.check_auto_directory_switch();
1131
1132 changed
1133 }
1134
1135 fn check_auto_hostname_switch(&mut self) -> bool {
1137 let tab = match self.tab_manager.active_tab_mut() {
1138 Some(t) => t,
1139 None => return false,
1140 };
1141
1142 let new_hostname = match tab.check_hostname_change() {
1143 Some(h) => h,
1144 None => {
1145 if tab.detected_hostname.is_none() && tab.auto_applied_profile_id.is_some() {
1146 crate::debug_info!(
1147 "PROFILE",
1148 "Clearing auto-applied hostname profile (returned to localhost)"
1149 );
1150 tab.auto_applied_profile_id = None;
1151 tab.profile_icon = None;
1152 tab.badge_override = None;
1153 if let Some(original) = tab.pre_profile_title.take() {
1155 tab.title = original;
1156 }
1157
1158 if tab.ssh_auto_switched {
1160 crate::debug_info!(
1161 "PROFILE",
1162 "Reverting SSH auto-switch (disconnected from remote host)"
1163 );
1164 tab.ssh_auto_switched = false;
1165 tab.pre_ssh_switch_profile = None;
1166 }
1167 }
1168 return false;
1169 }
1170 };
1171
1172 if let Some(existing_profile_id) = tab.auto_applied_profile_id
1174 && let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname)
1175 && profile.id == existing_profile_id
1176 {
1177 return false;
1178 }
1179
1180 if let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname) {
1181 let profile_name = profile.name.clone();
1182 let profile_id = profile.id;
1183 let profile_tab_name = profile.tab_name.clone();
1184 let profile_icon = profile.icon.clone();
1185 let profile_badge_text = profile.badge_text.clone();
1186 let profile_command = profile.command.clone();
1187 let profile_command_args = profile.command_args.clone();
1188
1189 crate::debug_info!(
1190 "PROFILE",
1191 "Auto-switching to profile '{}' for hostname '{}'",
1192 profile_name,
1193 new_hostname
1194 );
1195
1196 if let Some(tab) = self.tab_manager.active_tab_mut() {
1198 if !tab.ssh_auto_switched {
1200 tab.pre_ssh_switch_profile = tab.auto_applied_profile_id;
1201 tab.ssh_auto_switched = true;
1202 }
1203
1204 tab.auto_applied_profile_id = Some(profile_id);
1205 tab.profile_icon = profile_icon;
1206
1207 if tab.pre_profile_title.is_none() {
1209 tab.pre_profile_title = Some(tab.title.clone());
1210 }
1211 tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1213
1214 if let Some(badge_text) = profile_badge_text {
1216 tab.badge_override = Some(badge_text);
1217 }
1218
1219 if let Some(cmd) = profile_command {
1221 let mut full_cmd = cmd;
1222 if let Some(args) = profile_command_args {
1223 for arg in args {
1224 full_cmd.push(' ');
1225 full_cmd.push_str(&arg);
1226 }
1227 }
1228 full_cmd.push('\n');
1229
1230 let terminal_clone = Arc::clone(&tab.terminal);
1231 self.runtime.spawn(async move {
1232 let term = terminal_clone.lock().await;
1233 if let Err(e) = term.write(full_cmd.as_bytes()) {
1234 log::error!("Failed to execute profile command: {}", e);
1235 }
1236 });
1237 }
1238 }
1239
1240 self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1242
1243 log::info!(
1244 "Auto-applied profile '{}' for hostname '{}'",
1245 profile_name,
1246 new_hostname
1247 );
1248 true
1249 } else {
1250 crate::debug_info!(
1251 "PROFILE",
1252 "No profile matches hostname '{}' - consider creating one",
1253 new_hostname
1254 );
1255 false
1256 }
1257 }
1258
1259 fn check_ssh_command_switch(&mut self) -> bool {
1265 let (current_command, already_switched, has_hostname_profile) = {
1267 let tab = match self.tab_manager.active_tab() {
1268 Some(t) => t,
1269 None => return false,
1270 };
1271
1272 let cmd = if let Ok(term) = tab.terminal.try_lock() {
1273 term.get_running_command_name()
1274 } else {
1275 None
1276 };
1277
1278 (
1279 cmd,
1280 tab.ssh_auto_switched,
1281 tab.auto_applied_profile_id.is_some(),
1282 )
1283 };
1284
1285 let is_ssh = current_command
1286 .as_ref()
1287 .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
1288
1289 if is_ssh && !already_switched && !has_hostname_profile {
1290 if let Some(tab) = self.tab_manager.active_tab_mut() {
1295 crate::debug_info!(
1296 "PROFILE",
1297 "SSH command detected - waiting for hostname via OSC 7"
1298 );
1299 tab.ssh_auto_switched = true;
1302 }
1303 false
1304 } else if !is_ssh && already_switched && !has_hostname_profile {
1305 if let Some(tab) = self.tab_manager.active_tab_mut() {
1307 crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
1308 tab.ssh_auto_switched = false;
1309 let _prev_profile = tab.pre_ssh_switch_profile.take();
1310 tab.profile_icon = None;
1312 tab.badge_override = None;
1313 if let Some(original) = tab.pre_profile_title.take() {
1314 tab.title = original;
1315 }
1316 }
1317 true } else {
1319 false
1320 }
1321 }
1322
1323 fn check_auto_directory_switch(&mut self) -> bool {
1325 let tab = match self.tab_manager.active_tab_mut() {
1326 Some(t) => t,
1327 None => return false,
1328 };
1329
1330 if tab.auto_applied_profile_id.is_some() {
1332 return false;
1333 }
1334
1335 let new_cwd = match tab.check_cwd_change() {
1336 Some(c) => c,
1337 None => return false,
1338 };
1339
1340 if let Some(existing_profile_id) = tab.auto_applied_dir_profile_id
1342 && let Some(profile) = self.profile_manager.find_by_directory(&new_cwd)
1343 && profile.id == existing_profile_id
1344 {
1345 return false;
1346 }
1347
1348 if let Some(profile) = self.profile_manager.find_by_directory(&new_cwd) {
1349 let profile_name = profile.name.clone();
1350 let profile_id = profile.id;
1351 let profile_tab_name = profile.tab_name.clone();
1352 let profile_icon = profile.icon.clone();
1353 let profile_badge_text = profile.badge_text.clone();
1354 let profile_command = profile.command.clone();
1355 let profile_command_args = profile.command_args.clone();
1356
1357 crate::debug_info!(
1358 "PROFILE",
1359 "Auto-switching to profile '{}' for directory '{}'",
1360 profile_name,
1361 new_cwd
1362 );
1363
1364 if let Some(tab) = self.tab_manager.active_tab_mut() {
1366 tab.auto_applied_dir_profile_id = Some(profile_id);
1367 tab.profile_icon = profile_icon;
1368
1369 if tab.pre_profile_title.is_none() {
1371 tab.pre_profile_title = Some(tab.title.clone());
1372 }
1373 tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1375
1376 if let Some(badge_text) = profile_badge_text {
1378 tab.badge_override = Some(badge_text);
1379 }
1380
1381 if let Some(cmd) = profile_command {
1383 let mut full_cmd = cmd;
1384 if let Some(args) = profile_command_args {
1385 for arg in args {
1386 full_cmd.push(' ');
1387 full_cmd.push_str(&arg);
1388 }
1389 }
1390 full_cmd.push('\n');
1391
1392 let terminal_clone = Arc::clone(&tab.terminal);
1393 self.runtime.spawn(async move {
1394 let term = terminal_clone.lock().await;
1395 if let Err(e) = term.write(full_cmd.as_bytes()) {
1396 log::error!("Failed to execute profile command: {}", e);
1397 }
1398 });
1399 }
1400 }
1401
1402 self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1404
1405 log::info!(
1406 "Auto-applied profile '{}' for directory '{}'",
1407 profile_name,
1408 new_cwd
1409 );
1410 true
1411 } else {
1412 if let Some(tab) = self.tab_manager.active_tab_mut()
1414 && tab.auto_applied_dir_profile_id.is_some()
1415 {
1416 crate::debug_info!(
1417 "PROFILE",
1418 "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
1419 new_cwd
1420 );
1421 tab.auto_applied_dir_profile_id = None;
1422 tab.profile_icon = None;
1423 tab.badge_override = None;
1424 if let Some(original) = tab.pre_profile_title.take() {
1426 tab.title = original;
1427 }
1428 }
1429 false
1430 }
1431 }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use super::*;
1437 use std::collections::VecDeque;
1438 use std::time::{Duration, Instant};
1439
1440 fn make_info(title: &str, index: usize) -> ClosedTabInfo {
1441 ClosedTabInfo {
1442 cwd: Some("/tmp".to_string()),
1443 title: title.to_string(),
1444 has_default_title: true,
1445 index,
1446 closed_at: Instant::now(),
1447 pane_layout: None,
1448 custom_color: None,
1449 hidden_tab: None,
1450 }
1451 }
1452
1453 #[test]
1454 fn closed_tab_queue_overflow() {
1455 let max = 3;
1456 let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1457 for i in 0..5 {
1458 queue.push_front(make_info(&format!("tab{i}"), i));
1459 while queue.len() > max {
1460 queue.pop_back();
1461 }
1462 }
1463 assert_eq!(queue.len(), max);
1464 assert_eq!(queue.front().unwrap().title, "tab4");
1466 assert_eq!(queue.back().unwrap().title, "tab2");
1468 }
1469
1470 #[test]
1471 fn closed_tab_expiry() {
1472 let timeout = Duration::from_millis(50);
1473 let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1474
1475 let mut old = make_info("old", 0);
1477 old.closed_at = Instant::now() - Duration::from_millis(100);
1478 queue.push_front(old);
1479
1480 queue.push_front(make_info("fresh", 1));
1482
1483 let now = Instant::now();
1484 queue.retain(|info| now.duration_since(info.closed_at) < timeout);
1485
1486 assert_eq!(queue.len(), 1);
1487 assert_eq!(queue.front().unwrap().title, "fresh");
1488 }
1489}