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 physical_status_bar_height =
670 (status_bar_height + custom_status_bar_height) * scale;
671 let content_width = size.width as f32 - padding * 2.0;
672 let content_height =
673 size.height as f32 - content_offset_y - padding - physical_status_bar_height;
674 let bounds = crate::pane::PaneBounds::new(
675 padding,
676 content_offset_y,
677 content_width,
678 content_height,
679 );
680 tab.set_pane_bounds(bounds, cell_width, cell_height);
681 }
682
683 match tab.split_horizontal(&self.config, Arc::clone(&self.runtime), dpi_scale) {
684 Ok(Some(pane_id)) => {
685 log::info!("Split pane horizontally, new pane {}", pane_id);
686 if let Some(renderer) = &mut self.renderer {
688 renderer.clear_all_cells();
689 }
690 tab.cache.cells = None;
692 self.needs_redraw = true;
693 self.request_redraw();
694 }
695 Ok(None) => {
696 log::info!(
697 "Horizontal split not yet functional (renderer integration pending)"
698 );
699 }
700 Err(e) => {
701 log::error!("Failed to split pane horizontally: {}", e);
702 }
703 }
704 }
705 }
706
707 pub fn split_pane_vertical(&mut self) {
709 if self.is_tmux_connected() && self.split_pane_via_tmux(true) {
711 crate::debug_info!("TMUX", "Sent vertical split command to tmux");
712 return;
713 }
714 let is_tmux_connected = self.is_tmux_connected();
718 let status_bar_height =
719 crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
720 let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
721
722 let bounds_info = self.renderer.as_ref().map(|r| {
724 let size = r.size();
725 let padding = r.window_padding();
726 let content_offset_y = r.content_offset_y();
727 let cell_width = r.cell_width();
728 let cell_height = r.cell_height();
729 let scale = r.scale_factor();
730 (
731 size,
732 padding,
733 content_offset_y,
734 cell_width,
735 cell_height,
736 scale,
737 )
738 });
739
740 let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
741
742 if let Some(tab) = self.tab_manager.active_tab_mut() {
743 if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
745 bounds_info
746 {
747 let physical_status_bar_height =
749 (status_bar_height + custom_status_bar_height) * scale;
750 let content_width = size.width as f32 - padding * 2.0;
751 let content_height =
752 size.height as f32 - content_offset_y - padding - physical_status_bar_height;
753 let bounds = crate::pane::PaneBounds::new(
754 padding,
755 content_offset_y,
756 content_width,
757 content_height,
758 );
759 tab.set_pane_bounds(bounds, cell_width, cell_height);
760 }
761
762 match tab.split_vertical(&self.config, Arc::clone(&self.runtime), dpi_scale) {
763 Ok(Some(pane_id)) => {
764 log::info!("Split pane vertically, new pane {}", pane_id);
765 if let Some(renderer) = &mut self.renderer {
767 renderer.clear_all_cells();
768 }
769 tab.cache.cells = None;
771 self.needs_redraw = true;
772 self.request_redraw();
773 }
774 Ok(None) => {
775 log::info!("Vertical split not yet functional (renderer integration pending)");
776 }
777 Err(e) => {
778 log::error!("Failed to split pane vertically: {}", e);
779 }
780 }
781 }
782 }
783
784 pub fn close_focused_pane(&mut self) -> bool {
789 if self.is_tmux_connected() && self.close_pane_via_tmux() {
791 crate::debug_info!("TMUX", "Sent kill-pane command to tmux");
792 return false;
794 }
795 if self.config.confirm_close_running_jobs
799 && let Some(command_name) = self.check_current_pane_running_job()
800 && let Some(tab) = self.tab_manager.active_tab()
801 && let Some(pane_id) = tab.focused_pane_id()
802 {
803 let tab_id = tab.id;
804 let tab_title = if tab.title.is_empty() {
805 "Terminal".to_string()
806 } else {
807 tab.title.clone()
808 };
809 self.close_confirmation_ui
810 .show_for_pane(tab_id, pane_id, &tab_title, &command_name);
811 self.needs_redraw = true;
812 self.request_redraw();
813 return false; }
815
816 self.close_focused_pane_immediately()
817 }
818
819 fn close_focused_pane_immediately(&mut self) -> bool {
822 if let Some(tab) = self.tab_manager.active_tab_mut()
823 && tab.has_multiple_panes()
824 {
825 let is_last_pane = tab.close_focused_pane();
826 if is_last_pane {
827 return self.close_current_tab_immediately();
829 }
830 self.needs_redraw = true;
831 self.request_redraw();
832 return false;
833 }
834 self.close_current_tab_immediately()
836 }
837
838 fn check_current_tab_running_job(&self) -> Option<String> {
842 let tab = self.tab_manager.active_tab()?;
843 let term = tab.terminal.try_lock().ok()?;
844 term.should_confirm_close(&self.config.jobs_to_ignore)
845 }
846
847 fn check_current_pane_running_job(&self) -> Option<String> {
851 let tab = self.tab_manager.active_tab()?;
852
853 if tab.has_multiple_panes() {
855 let pane_manager = tab.pane_manager()?;
856 let focused_id = pane_manager.focused_pane_id()?;
857 let pane = pane_manager.get_pane(focused_id)?;
858 let term = pane.terminal.try_lock().ok()?;
859 return term.should_confirm_close(&self.config.jobs_to_ignore);
860 }
861
862 let term = tab.terminal.try_lock().ok()?;
864 term.should_confirm_close(&self.config.jobs_to_ignore)
865 }
866
867 pub fn has_multiple_panes(&self) -> bool {
869 self.tab_manager
870 .active_tab()
871 .is_some_and(|tab| tab.has_multiple_panes())
872 }
873
874 pub fn navigate_pane(&mut self, direction: crate::pane::NavigationDirection) {
876 if let Some(tab) = self.tab_manager.active_tab_mut()
877 && tab.has_multiple_panes()
878 {
879 tab.navigate_pane(direction);
880 self.needs_redraw = true;
881 self.request_redraw();
882 }
883 }
884
885 pub fn resize_pane(&mut self, direction: crate::pane::NavigationDirection) {
889 use crate::pane::NavigationDirection;
890
891 const RESIZE_DELTA: f32 = 0.05;
893
894 let delta = match direction {
898 NavigationDirection::Right | NavigationDirection::Down => RESIZE_DELTA,
899 NavigationDirection::Left | NavigationDirection::Up => -RESIZE_DELTA,
900 };
901
902 if let Some(tab) = self.tab_manager.active_tab_mut()
903 && let Some(pm) = tab.pane_manager_mut()
904 && let Some(focused_id) = pm.focused_pane_id()
905 {
906 pm.resize_split(focused_id, delta);
907 self.needs_redraw = true;
908 self.request_redraw();
909 }
910 }
911
912 pub fn open_profile(&mut self, profile_id: ProfileId) {
918 log::debug!("open_profile called with id: {:?}", profile_id);
919
920 if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
922 log::warn!(
923 "Cannot open profile: max_tabs limit ({}) reached",
924 self.config.max_tabs
925 );
926 self.deliver_notification(
927 "Tab Limit Reached",
928 &format!(
929 "Cannot open profile: maximum of {} tabs already open",
930 self.config.max_tabs
931 ),
932 );
933 return;
934 }
935
936 let profile = match self.profile_manager.get(&profile_id) {
937 Some(p) => p.clone(),
938 None => {
939 log::error!("Profile not found: {:?}", profile_id);
940 return;
941 }
942 };
943 log::debug!("Found profile: {}", profile.name);
944
945 let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
947
948 match self.tab_manager.new_tab_from_profile(
949 &self.config,
950 Arc::clone(&self.runtime),
951 &profile,
952 grid_size,
953 ) {
954 Ok(tab_id) => {
955 if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
957 tab.profile_icon = profile.icon.clone();
958 }
959
960 if let Some(window) = &self.window
962 && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
963 {
964 tab.start_refresh_task(
965 Arc::clone(&self.runtime),
966 Arc::clone(window),
967 self.config.max_fps,
968 );
969
970 if let Some(renderer) = &self.renderer
972 && let Ok(mut term) = tab.terminal.try_lock()
973 {
974 let (cols, rows) = renderer.grid_size();
975 let size = renderer.size();
976 let width_px = size.width as usize;
977 let height_px = size.height as usize;
978
979 term.set_cell_dimensions(
980 renderer.cell_width() as u32,
981 renderer.cell_height() as u32,
982 );
983 let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
984 log::info!(
985 "Opened profile '{}' in tab {} ({}x{} at {}x{} px)",
986 profile.name,
987 tab_id,
988 cols,
989 rows,
990 width_px,
991 height_px
992 );
993 }
994 }
995
996 self.apply_profile_badge(&profile);
998
999 self.needs_redraw = true;
1000 self.request_redraw();
1001 }
1002 Err(e) => {
1003 log::error!("Failed to open profile '{}': {}", profile.name, e);
1004
1005 let error_msg = e.to_string();
1007 let (title, message) = if error_msg.contains("Unable to spawn")
1008 || error_msg.contains("No viable candidates")
1009 {
1010 let cmd = profile
1012 .command
1013 .as_deref()
1014 .unwrap_or("the configured command");
1015 (
1016 format!("Profile '{}' Failed", profile.name),
1017 format!(
1018 "Command '{}' not found. Check that it's installed and in your PATH.",
1019 cmd
1020 ),
1021 )
1022 } else if error_msg.contains("No such file or directory") {
1023 (
1024 format!("Profile '{}' Failed", profile.name),
1025 format!(
1026 "Working directory not found: {}",
1027 profile.working_directory.as_deref().unwrap_or("(unknown)")
1028 ),
1029 )
1030 } else {
1031 (
1032 format!("Profile '{}' Failed", profile.name),
1033 format!("Failed to start: {}", error_msg),
1034 )
1035 };
1036 self.deliver_notification(&title, &message);
1037 }
1038 }
1039 }
1040
1041 pub(crate) fn apply_profile_badge(&mut self, profile: &crate::profile::Profile) {
1046 {
1048 let mut vars = self.badge_state.variables_mut();
1049 vars.profile_name = profile.name.clone();
1050 }
1051
1052 self.badge_state.apply_profile_settings(profile);
1054
1055 if profile.badge_text.is_some() {
1056 crate::debug_info!(
1057 "PROFILE",
1058 "Applied profile badge settings: format='{}', color={:?}, alpha={}",
1059 profile.badge_text.as_deref().unwrap_or(""),
1060 profile.badge_color,
1061 profile.badge_color_alpha.unwrap_or(0.0)
1062 );
1063 }
1064
1065 self.badge_state.mark_dirty();
1067 }
1068
1069 pub fn toggle_profile_drawer(&mut self) {
1071 self.profile_drawer_ui.toggle();
1072 self.needs_redraw = true;
1073 self.request_redraw();
1074 }
1075
1076 pub fn save_profiles(&self) {
1078 if let Err(e) = profile_storage::save_profiles(&self.profile_manager) {
1079 log::error!("Failed to save profiles: {}", e);
1080 }
1081 }
1082
1083 pub fn apply_profile_changes(&mut self, profiles: Vec<crate::profile::Profile>) {
1085 self.profile_manager = ProfileManager::from_profiles(profiles);
1086 self.save_profiles();
1087 self.profiles_menu_needs_update = true;
1089 }
1090
1091 pub fn check_auto_profile_switch(&mut self) -> bool {
1099 if self.profile_manager.is_empty() {
1100 return false;
1101 }
1102
1103 let mut changed = false;
1104
1105 changed |= self.check_auto_hostname_switch();
1107
1108 if !changed {
1110 changed |= self.check_ssh_command_switch();
1111 }
1112
1113 changed |= self.check_auto_directory_switch();
1115
1116 changed
1117 }
1118
1119 fn check_auto_hostname_switch(&mut self) -> bool {
1121 let tab = match self.tab_manager.active_tab_mut() {
1122 Some(t) => t,
1123 None => return false,
1124 };
1125
1126 let new_hostname = match tab.check_hostname_change() {
1127 Some(h) => h,
1128 None => {
1129 if tab.detected_hostname.is_none() && tab.auto_applied_profile_id.is_some() {
1130 crate::debug_info!(
1131 "PROFILE",
1132 "Clearing auto-applied hostname profile (returned to localhost)"
1133 );
1134 tab.auto_applied_profile_id = None;
1135 tab.profile_icon = None;
1136 tab.badge_override = None;
1137 if let Some(original) = tab.pre_profile_title.take() {
1139 tab.title = original;
1140 }
1141
1142 if tab.ssh_auto_switched {
1144 crate::debug_info!(
1145 "PROFILE",
1146 "Reverting SSH auto-switch (disconnected from remote host)"
1147 );
1148 tab.ssh_auto_switched = false;
1149 tab.pre_ssh_switch_profile = None;
1150 }
1151 }
1152 return false;
1153 }
1154 };
1155
1156 if let Some(existing_profile_id) = tab.auto_applied_profile_id
1158 && let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname)
1159 && profile.id == existing_profile_id
1160 {
1161 return false;
1162 }
1163
1164 if let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname) {
1165 let profile_name = profile.name.clone();
1166 let profile_id = profile.id;
1167 let profile_tab_name = profile.tab_name.clone();
1168 let profile_icon = profile.icon.clone();
1169 let profile_badge_text = profile.badge_text.clone();
1170 let profile_command = profile.command.clone();
1171 let profile_command_args = profile.command_args.clone();
1172
1173 crate::debug_info!(
1174 "PROFILE",
1175 "Auto-switching to profile '{}' for hostname '{}'",
1176 profile_name,
1177 new_hostname
1178 );
1179
1180 if let Some(tab) = self.tab_manager.active_tab_mut() {
1182 if !tab.ssh_auto_switched {
1184 tab.pre_ssh_switch_profile = tab.auto_applied_profile_id;
1185 tab.ssh_auto_switched = true;
1186 }
1187
1188 tab.auto_applied_profile_id = Some(profile_id);
1189 tab.profile_icon = profile_icon;
1190
1191 if tab.pre_profile_title.is_none() {
1193 tab.pre_profile_title = Some(tab.title.clone());
1194 }
1195 tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1197
1198 if let Some(badge_text) = profile_badge_text {
1200 tab.badge_override = Some(badge_text);
1201 }
1202
1203 if let Some(cmd) = profile_command {
1205 let mut full_cmd = cmd;
1206 if let Some(args) = profile_command_args {
1207 for arg in args {
1208 full_cmd.push(' ');
1209 full_cmd.push_str(&arg);
1210 }
1211 }
1212 full_cmd.push('\n');
1213
1214 let terminal_clone = Arc::clone(&tab.terminal);
1215 self.runtime.spawn(async move {
1216 let term = terminal_clone.lock().await;
1217 if let Err(e) = term.write(full_cmd.as_bytes()) {
1218 log::error!("Failed to execute profile command: {}", e);
1219 }
1220 });
1221 }
1222 }
1223
1224 self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1226
1227 log::info!(
1228 "Auto-applied profile '{}' for hostname '{}'",
1229 profile_name,
1230 new_hostname
1231 );
1232 true
1233 } else {
1234 crate::debug_info!(
1235 "PROFILE",
1236 "No profile matches hostname '{}' - consider creating one",
1237 new_hostname
1238 );
1239 false
1240 }
1241 }
1242
1243 fn check_ssh_command_switch(&mut self) -> bool {
1249 let (current_command, already_switched, has_hostname_profile) = {
1251 let tab = match self.tab_manager.active_tab() {
1252 Some(t) => t,
1253 None => return false,
1254 };
1255
1256 let cmd = if let Ok(term) = tab.terminal.try_lock() {
1257 term.get_running_command_name()
1258 } else {
1259 None
1260 };
1261
1262 (
1263 cmd,
1264 tab.ssh_auto_switched,
1265 tab.auto_applied_profile_id.is_some(),
1266 )
1267 };
1268
1269 let is_ssh = current_command
1270 .as_ref()
1271 .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
1272
1273 if is_ssh && !already_switched && !has_hostname_profile {
1274 if let Some(tab) = self.tab_manager.active_tab_mut() {
1279 crate::debug_info!(
1280 "PROFILE",
1281 "SSH command detected - waiting for hostname via OSC 7"
1282 );
1283 tab.ssh_auto_switched = true;
1286 }
1287 false
1288 } else if !is_ssh && already_switched && !has_hostname_profile {
1289 if let Some(tab) = self.tab_manager.active_tab_mut() {
1291 crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
1292 tab.ssh_auto_switched = false;
1293 let _prev_profile = tab.pre_ssh_switch_profile.take();
1294 tab.profile_icon = None;
1296 tab.badge_override = None;
1297 if let Some(original) = tab.pre_profile_title.take() {
1298 tab.title = original;
1299 }
1300 }
1301 true } else {
1303 false
1304 }
1305 }
1306
1307 fn check_auto_directory_switch(&mut self) -> bool {
1309 let tab = match self.tab_manager.active_tab_mut() {
1310 Some(t) => t,
1311 None => return false,
1312 };
1313
1314 if tab.auto_applied_profile_id.is_some() {
1316 return false;
1317 }
1318
1319 let new_cwd = match tab.check_cwd_change() {
1320 Some(c) => c,
1321 None => return false,
1322 };
1323
1324 if let Some(existing_profile_id) = tab.auto_applied_dir_profile_id
1326 && let Some(profile) = self.profile_manager.find_by_directory(&new_cwd)
1327 && profile.id == existing_profile_id
1328 {
1329 return false;
1330 }
1331
1332 if let Some(profile) = self.profile_manager.find_by_directory(&new_cwd) {
1333 let profile_name = profile.name.clone();
1334 let profile_id = profile.id;
1335 let profile_tab_name = profile.tab_name.clone();
1336 let profile_icon = profile.icon.clone();
1337 let profile_badge_text = profile.badge_text.clone();
1338 let profile_command = profile.command.clone();
1339 let profile_command_args = profile.command_args.clone();
1340
1341 crate::debug_info!(
1342 "PROFILE",
1343 "Auto-switching to profile '{}' for directory '{}'",
1344 profile_name,
1345 new_cwd
1346 );
1347
1348 if let Some(tab) = self.tab_manager.active_tab_mut() {
1350 tab.auto_applied_dir_profile_id = Some(profile_id);
1351 tab.profile_icon = profile_icon;
1352
1353 if tab.pre_profile_title.is_none() {
1355 tab.pre_profile_title = Some(tab.title.clone());
1356 }
1357 tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1359
1360 if let Some(badge_text) = profile_badge_text {
1362 tab.badge_override = Some(badge_text);
1363 }
1364
1365 if let Some(cmd) = profile_command {
1367 let mut full_cmd = cmd;
1368 if let Some(args) = profile_command_args {
1369 for arg in args {
1370 full_cmd.push(' ');
1371 full_cmd.push_str(&arg);
1372 }
1373 }
1374 full_cmd.push('\n');
1375
1376 let terminal_clone = Arc::clone(&tab.terminal);
1377 self.runtime.spawn(async move {
1378 let term = terminal_clone.lock().await;
1379 if let Err(e) = term.write(full_cmd.as_bytes()) {
1380 log::error!("Failed to execute profile command: {}", e);
1381 }
1382 });
1383 }
1384 }
1385
1386 self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1388
1389 log::info!(
1390 "Auto-applied profile '{}' for directory '{}'",
1391 profile_name,
1392 new_cwd
1393 );
1394 true
1395 } else {
1396 if let Some(tab) = self.tab_manager.active_tab_mut()
1398 && tab.auto_applied_dir_profile_id.is_some()
1399 {
1400 crate::debug_info!(
1401 "PROFILE",
1402 "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
1403 new_cwd
1404 );
1405 tab.auto_applied_dir_profile_id = None;
1406 tab.profile_icon = None;
1407 tab.badge_override = None;
1408 if let Some(original) = tab.pre_profile_title.take() {
1410 tab.title = original;
1411 }
1412 }
1413 false
1414 }
1415 }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421 use std::collections::VecDeque;
1422 use std::time::{Duration, Instant};
1423
1424 fn make_info(title: &str, index: usize) -> ClosedTabInfo {
1425 ClosedTabInfo {
1426 cwd: Some("/tmp".to_string()),
1427 title: title.to_string(),
1428 has_default_title: true,
1429 index,
1430 closed_at: Instant::now(),
1431 pane_layout: None,
1432 custom_color: None,
1433 hidden_tab: None,
1434 }
1435 }
1436
1437 #[test]
1438 fn closed_tab_queue_overflow() {
1439 let max = 3;
1440 let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1441 for i in 0..5 {
1442 queue.push_front(make_info(&format!("tab{i}"), i));
1443 while queue.len() > max {
1444 queue.pop_back();
1445 }
1446 }
1447 assert_eq!(queue.len(), max);
1448 assert_eq!(queue.front().unwrap().title, "tab4");
1450 assert_eq!(queue.back().unwrap().title, "tab2");
1452 }
1453
1454 #[test]
1455 fn closed_tab_expiry() {
1456 let timeout = Duration::from_millis(50);
1457 let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1458
1459 let mut old = make_info("old", 0);
1461 old.closed_at = Instant::now() - Duration::from_millis(100);
1462 queue.push_front(old);
1463
1464 queue.push_front(make_info("fresh", 1));
1466
1467 let now = Instant::now();
1468 queue.retain(|info| now.duration_since(info.closed_at) < timeout);
1469
1470 assert_eq!(queue.len(), 1);
1471 assert_eq!(queue.front().unwrap().title, "fresh");
1472 }
1473}