1use std::cell::{Cell, RefCell};
21use std::io::Write as _;
22use std::path::{Path, PathBuf};
23
24use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
25use ratatui::{
26 Frame,
27 layout::Rect,
28 style::{Color, Style},
29 widgets::{Block, Borders, Paragraph},
30};
31
32fn extract_issues_with_state(
45 ex: &crate::diagnostics_extractor::DiagnosticsExtractor,
46 line: &crate::vt_parser::StyledLine,
47 prev_sev: &mut Option<(crate::issue_registry::Severity, String)>,
48) -> Vec<crate::issue_registry::NewIssue> {
49 let text = &line.text;
50
51 if let Some((path, ln, col)) = ex.try_rustc_arrow(text) {
53 if let Some((sev, msg)) = prev_sev.take() {
54 return vec![ex.make_issue(sev, msg, Some(path), Some(ln), Some(col))];
55 }
56 return Vec::new();
58 }
59
60 if let Some(header) = ex.try_rustc_header(text) {
62 *prev_sev = Some(header);
63 return Vec::new();
64 }
65
66 *prev_sev = None;
68 ex.extract_from_line(line)
69}
70
71const MAX_SCROLL_STEP: u16 = 1000;
72const DEFAULT_SCROLL_SIZE: u16 = 5;
74const MAX_SCROLL_SIZE: u16 = 100;
76use serde::{Deserialize, Serialize};
77use tokio::sync::mpsc::UnboundedSender;
78
79use crate::prelude::*;
80
81use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
82use operation::{Event, Operation, TerminalOp};
83use settings::Settings;
84use settings::adapters::terminal;
85use views::View;
86use widgets::button::Menu;
87use widgets::input_field::InputField;
88
89pub struct TerminalTab {
94 pub id: u64,
95 pub title: String,
96 pub command: String,
98 pub cwd: PathBuf,
99 pub master: Box<dyn MasterPty + Send>,
100 pub writer: Box<dyn std::io::Write + Send>,
101 pub parser: RefCell<vt100::Parser>,
102 pub links: Vec<crate::widgets::terminal::Link>,
104 pub scroll_offset: u16,
106 pub scrollback_len: usize,
108 pub exited: bool,
109 pub scrollback_lines: Vec<crate::vt_parser::StyledLine>,
113}
114
115enum TabPopup {
120 Menu { id: u64, cursor: usize },
122 Rename { id: u64, input: InputField },
124 Selector { cursor: usize },
126}
127
128const MENU_ITEMS: &[&str] = &["Rename", "Close"];
129
130pub struct TerminalSearch {
135 pub query: crate::widgets::input_field::InputField,
137 pub opts: crate::views::editor::SearchOptions,
139 pub matches: Vec<(usize, usize, usize)>,
141 pub current: Option<usize>,
143}
144
145fn find_matches_styled(
147 lines: &[crate::vt_parser::StyledLine],
148 query: &str,
149 opts: &crate::views::editor::SearchOptions,
150) -> Vec<(usize, usize, usize)> {
151 if query.is_empty() {
152 return Vec::new();
153 }
154 let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
155 let mut out = Vec::new();
156 if opts.regex {
157 if let Ok(re) = regex::RegexBuilder::new(query).case_insensitive(ignore).build() {
158 for (row, line) in lines.iter().enumerate() {
159 for m in re.find_iter(&line.text) {
160 out.push((row, m.start(), m.end()));
161 }
162 }
163 }
164 } else {
165 let q = if ignore { query.to_lowercase() } else { query.to_owned() };
166 for (row, line) in lines.iter().enumerate() {
167 let l = if ignore { line.text.to_lowercase() } else { line.text.clone() };
168 let mut start = 0usize;
169 while let Some(pos) = l[start..].find(&q) {
170 let abs = start + pos;
171 out.push((row, abs, abs + q.len()));
172 start = abs + 1;
173 }
174 }
175 }
176 out
177}
178
179pub struct TerminalView {
184 pub tabs: Vec<TerminalTab>,
185 pub active: usize,
186 popup: Option<TabPopup>,
187 pub search: Option<TerminalSearch>,
189 pub last_search: Option<TerminalSearch>,
191 pub next_id: u64,
193 last_size: Cell<(u16, u16)>,
195 scroll_size: u16,
197}
198
199impl std::fmt::Debug for TerminalView {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct("TerminalView")
202 .field("active", &self.active)
203 .field("next_id", &self.next_id)
204 .field("tabs_len", &self.tabs.len())
205 .finish()
206 }
207}
208
209impl Default for TerminalView {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215impl TerminalView {
216 pub fn new() -> Self {
217 let size = crossterm::terminal::size().unwrap_or((0, 0));
219 Self {
220 tabs: Vec::new(),
221 active: 0,
222 popup: None,
223 search: None,
224 last_search: None,
225 next_id: 1,
226 last_size: Cell::new(size),
227 scroll_size: DEFAULT_SCROLL_SIZE,
228 }
229 }
230
231 pub fn update_from_settings(&mut self, settings: &Settings) {
234 let size = *terminal::scroll_size(settings);
235 self.scroll_size = size.clamp(1, MAX_SCROLL_SIZE as i64) as u16;
237 }
238
239 fn alloc_id(&mut self) -> u64 {
245 let id = self.next_id;
246 self.next_id += 1;
247 id
248 }
249
250 pub fn spawn_tab(
254 &mut self,
255 command: Option<String>,
256 shell_setting: &str,
257 cwd: &Path,
258 op_tx: &UnboundedSender<Vec<Operation>>,
259 scrollback: usize,
260 ) {
261 let id = self.alloc_id();
262
263 let (cols, rows) = self.last_size.get();
265 let cols = if cols == 0 { 80 } else { cols };
266 let rows = if rows == 0 { 24 } else { rows };
267 let term_rows = rows.saturating_sub(1).max(1);
271 log::debug!(
272 "terminal.spawn_tab: cols={} rows={} term_rows={}",
273 cols,
274 rows,
275 term_rows
276 );
277
278 let shell = if let Some(ref cmd) = command {
280 cmd.clone()
281 } else if !shell_setting.is_empty() {
282 shell_setting.to_string()
283 } else {
284 std::env::var("SHELL").unwrap_or_else(|_| {
285 if cfg!(windows) {
286 std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
287 } else {
288 "/bin/bash".to_string()
289 }
290 })
291 };
292
293 let title = shell
295 .split_whitespace()
296 .next()
297 .and_then(|s| s.rsplit('/').next())
298 .unwrap_or("shell")
299 .to_string();
300
301 let pty_system = native_pty_system();
303 let pair = match pty_system.openpty(PtySize {
304 rows: term_rows,
305 cols,
306 pixel_width: 0,
307 pixel_height: 0,
308 }) {
309 Ok(p) => p,
310 Err(e) => {
311 log::error!("terminal: openpty failed: {e}");
312 return;
313 }
314 };
315
316 let mut cmd = CommandBuilder::new(&shell);
318 cmd.cwd(cwd);
319
320 let child = match pair.slave.spawn_command(cmd) {
321 Ok(c) => c,
322 Err(e) => {
323 log::error!("terminal: spawn_command failed: {e}");
324 return;
325 }
326 };
327 drop(pair.slave);
329
330 let writer = match pair.master.take_writer() {
331 Ok(w) => w,
332 Err(e) => {
333 log::error!("terminal: take_writer failed: {e}");
334 return;
335 }
336 };
337 let reader = match pair.master.try_clone_reader() {
338 Ok(r) => r,
339 Err(e) => {
340 log::error!("terminal: try_clone_reader failed: {e}");
341 return;
342 }
343 };
344
345 let parser = RefCell::new(vt100::Parser::new(term_rows, cols, scrollback));
346
347 self.tabs.push(TerminalTab {
348 id,
349 title,
350 command: shell,
351 cwd: cwd.to_path_buf(),
352 master: pair.master,
353 writer,
354 parser,
355 links: Vec::new(),
356 scroll_offset: 0,
357 scrollback_len: scrollback,
358 exited: false,
359 scrollback_lines: Vec::new(),
360 });
361 self.active = self.tabs.len() - 1;
362
363 let tx = op_tx.clone();
369 let extractor = std::sync::Arc::new(
370 crate::diagnostics_extractor::DiagnosticsExtractor::new(
371 format!("terminal:{id}"),
372 "terminal",
373 ),
374 );
375 let ex = std::sync::Arc::clone(&extractor);
376 tokio::task::spawn_blocking(move || {
377 let _child = child; let mut reader = reader;
379 let mut buf = [0u8; 4096];
380 let mut tp = crate::vt_parser::TerminalParser::new();
382 let mut prev_sev: Option<(crate::issue_registry::Severity, String)> = None;
384
385 loop {
386 match std::io::Read::read(&mut reader, &mut buf) {
387 Ok(0) | Err(_) => {
388 if let Some(line) = tp.flush() {
390 let issues = extract_issues_with_state(&ex, &line, &mut prev_sev);
391 let mut ops =
392 vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
393 id,
394 lines: vec![line],
395 })];
396 ops.extend(issues.into_iter().map(|i| Operation::AddIssue { issue: i }));
397 let _ = tx.send(ops);
398 }
399 let _ =
400 tx.send(vec![Operation::TerminalLocal(TerminalOp::ProcessExited {
401 id,
402 })]);
403 break;
404 }
405 Ok(n) => {
406 let data = buf[..n].to_vec();
407 let _ = tx.send(vec![Operation::TerminalLocal(TerminalOp::Output {
409 id,
410 data: data.clone(),
411 })]);
412 let styled_lines = tp.push(&data);
414 if !styled_lines.is_empty() {
415 let mut issue_ops: Vec<Operation> = Vec::new();
416 for line in &styled_lines {
417 issue_ops.extend(
418 extract_issues_with_state(&ex, line, &mut prev_sev)
419 .into_iter()
420 .map(|i| Operation::AddIssue { issue: i }),
421 );
422 }
423 let mut ops =
424 vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
425 id,
426 lines: styled_lines,
427 })];
428 ops.extend(issue_ops);
429 let _ = tx.send(ops);
430 }
431 }
432 }
433 }
434 });
435 }
436
437 pub fn write_input(&mut self, id: u64, data: &[u8]) {
439 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
440 && let Err(e) = tab.writer.write_all(data)
441 {
442 log::warn!("terminal: write_input failed for tab {id}: {e}");
443 }
444 }
445
446 pub fn resize_all(&mut self, rows: u16, cols: u16) {
448 let term_rows = rows.saturating_sub(1).max(1); for tab in &mut self.tabs {
450 let _ = tab.master.resize(PtySize {
451 rows: term_rows,
452 cols,
453 pixel_width: 0,
454 pixel_height: 0,
455 });
456 tab.parser.borrow_mut().set_size(term_rows, cols);
457 }
458 self.last_size.set((cols, rows));
459 }
460
461 fn active_id(&self) -> Option<u64> {
466 self.tabs.get(self.active).map(|t| t.id)
467 }
468
469 pub fn detect_links_for_tab(tab: &TerminalTab) -> Vec<crate::widgets::terminal::Link> {
470 crate::widgets::terminal::detect_links_from_screen(&tab.parser.borrow(), &tab.cwd)
471 }
472
473 fn close_tab_by_id(&mut self, id: u64) {
474 if let Some(idx) = self.tabs.iter().position(|t| t.id == id) {
475 self.tabs.remove(idx);
476 if self.active >= self.tabs.len() && !self.tabs.is_empty() {
477 self.active = self.tabs.len() - 1;
478 }
479 }
480 }
481
482 pub fn to_state(&self) -> TerminalStateStore {
487 TerminalStateStore {
488 tabs: self
489 .tabs
490 .iter()
491 .map(|t| TerminalTabState {
492 id: t.id,
493 title: t.title.clone(),
494 command: t.command.clone(),
495 cwd: t.cwd.clone(),
496 })
497 .collect(),
498 active: self.active,
499 next_id: self.next_id,
500 }
501 }
502
503 fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
508 let ctrl = key.modifiers.contains(Modifiers::CTRL);
509 let alt = key.modifiers.contains(Modifiers::ALT);
510
511 match key.key {
512 Key::Char(c) if ctrl => {
513 let lc = c.to_ascii_lowercase();
514 if lc.is_ascii_lowercase() {
515 Some(vec![lc as u8 - b'a' + 1])
516 } else {
517 None
518 }
519 }
520 Key::Char(c) if alt => {
521 Some(vec![0x1b, c as u8])
523 }
524 Key::Char(c) => Some(c.to_string().into_bytes()),
525 Key::Enter => Some(b"\r".to_vec()),
526 Key::Backspace => Some(vec![0x7f]),
527 Key::Tab => Some(b"\t".to_vec()),
528 Key::Delete => Some(b"\x1b[3~".to_vec()),
529 Key::ArrowUp => Some(b"\x1b[A".to_vec()),
530 Key::ArrowDown => Some(b"\x1b[B".to_vec()),
531 Key::ArrowRight => Some(b"\x1b[C".to_vec()),
532 Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
533 Key::Home => Some(b"\x1b[H".to_vec()),
534 Key::End => Some(b"\x1b[F".to_vec()),
535 Key::PageUp => Some(b"\x1b[5~".to_vec()),
536 Key::PageDown => Some(b"\x1b[6~".to_vec()),
537 Key::F(1) => Some(b"\x1bOP".to_vec()),
538 Key::F(2) => Some(b"\x1bOQ".to_vec()),
539 Key::F(3) => Some(b"\x1bOR".to_vec()),
540 Key::F(4) => Some(b"\x1bOS".to_vec()),
541 Key::F(n) => {
542 let code: u8 = match n {
544 5 => 15,
545 6 => 17,
546 7 => 18,
547 8 => 19,
548 9 => 20,
549 10 => 21,
550 11 => 23,
551 12 => 24,
552 _ => return None,
553 };
554 Some(format!("\x1b[{}~", code).into_bytes())
555 }
556 _ => None,
557 }
558 }
559
560 fn render_menu_popup(&self, frame: &mut Frame, _id: u64, cursor: usize) {
565 let area = frame.area();
566 let popup_rect = Rect {
567 x: area.x,
568 y: area.y + 1,
569 width: 18,
570 height: (MENU_ITEMS.len() + 2) as u16,
571 };
572 if popup_rect.bottom() > area.bottom() || popup_rect.right() > area.right() {
573 return;
574 }
575
576 let menu = Menu::new(MENU_ITEMS).cursor(cursor);
577 frame.render_widget(menu, popup_rect);
578 }
579
580 fn render_rename_popup(&self, frame: &mut Frame, _id: u64, input: &str) {
581 let area = frame.area();
582 let popup_rect = Rect {
583 x: area.x,
584 y: area.y + 1,
585 width: 30.min(area.width),
586 height: 3,
587 };
588 if popup_rect.bottom() > area.bottom() {
589 return;
590 }
591 let display = format!(" {}_ ", input);
592 let p = Paragraph::new(display).block(
593 Block::default()
594 .borders(Borders::ALL)
595 .title(" Rename ")
596 .style(
597 Style::default()
598 .fg(Color::Rgb(100, 100, 100))
599 .bg(Color::Rgb(40, 40, 40)),
600 ),
601 );
602 frame.render_widget(p, popup_rect);
603 }
604
605
606 fn render_selector_popup(&self, frame: &mut Frame, area: Rect, cursor: usize) {
607 let n = self.tabs.len();
608 if n == 0 {
609 return;
610 }
611 let height = (n as u16 + 2).min(area.height);
612 let width = self
613 .tabs
614 .iter()
615 .map(|t| t.title.chars().count())
616 .max()
617 .unwrap_or(10) as u16
618 + 4;
619 let width = width.min(area.width).max(14);
620 let y = area.bottom().saturating_sub(height);
622 let popup_rect = Rect { x: area.x + 1, y, width, height };
623 let tab_names: Vec<&str> = self.tabs.iter().map(|t| t.title.as_str()).collect();
624 let menu = Menu::new(&tab_names).cursor(cursor);
625 frame.render_widget(menu, popup_rect);
626 }
627}
628
629impl TerminalView {
634 pub fn handle_paste(&self, text: &str) -> Vec<Operation> {
636 let Some(id) = self.active_id() else {
637 return vec![];
638 };
639 let data = text.as_bytes().to_vec();
640 vec![
641 Operation::TerminalLocal(TerminalOp::ScrollReset),
642 Operation::TerminalInput { id, data },
643 ]
644 }
645}
646
647impl View for TerminalView {
652 const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
653
654 fn save_state(&mut self, app: &mut crate::app_state::AppState) {
655 crate::views::save_state::terminal_pre_save(self, app);
656 }
657
658 fn status_bar(
659 &self,
660 _state: &crate::app_state::AppState,
661 bar: &mut crate::widgets::status_bar::StatusBarBuilder,
662 ) {
663 let name = self
664 .tabs
665 .get(self.active)
666 .map(|t| t.title.as_str())
667 .unwrap_or("(no tab)");
668 bar.menu(
669 format!("tab: {}", name),
670 crate::commands::CommandId::new_static("terminal", "open_tab_selector"),
671 );
672 }
673
674 fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
675 if let Some(TabPopup::Rename { input, .. }) = &self.popup {
677 return match key.key {
678 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::RenameCancel)],
679 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::RenameConfirm)],
680 _ => {
681 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
682 let mut f = input.clone();
683 f.apply(&field_op);
684 vec![Operation::TerminalLocal(TerminalOp::RenameChanged(
685 f.text().to_owned(),
686 ))]
687 } else {
688 vec![]
689 }
690 }
691 };
692 }
693
694 if let Some(TabPopup::Selector { .. }) = &self.popup {
696 return match key.key {
697 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
698 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
699 Key::ArrowUp => vec![Operation::NavigateUp],
700 Key::ArrowDown => vec![Operation::NavigateDown],
701 _ => vec![],
702 };
703 }
704
705 if let Some(TabPopup::Menu { .. }) = &self.popup {
707 return match key.key {
708 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
709 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
710 Key::ArrowUp => vec![Operation::NavigateUp],
711 Key::ArrowDown => vec![Operation::NavigateDown],
712 _ => vec![],
713 };
714 }
715
716 if self.search.is_some() {
718 let alt = key.modifiers.contains(Modifiers::ALT);
719 return match (alt, key.key) {
720 (_, Key::Escape) => vec![Operation::SearchLocal(crate::operation::SearchOp::Close)],
721 (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
722 vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)]
723 }
724 (_, Key::F(3)) => {
725 vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)]
726 }
727 (true, Key::Char('c')) | (true, Key::Char('C')) => {
728 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleIgnoreCase)]
729 }
730 (true, Key::Char('r')) | (true, Key::Char('R')) => {
731 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleRegex)]
732 }
733 (true, Key::Char('s')) | (true, Key::Char('S')) => {
734 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleSmartCase)]
735 }
736 _ => {
737 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
738 vec![Operation::SearchLocal(crate::operation::SearchOp::QueryInput(field_op))]
739 } else {
740 vec![]
741 }
742 }
743 };
744 }
745
746 let ctrl = key.modifiers.contains(Modifiers::CTRL);
748 let alt = key.modifiers.contains(Modifiers::ALT);
749
750 if ctrl && !alt {
752 match key.key {
753 Key::Char('t') | Key::Char('T') => {
754 return vec![Operation::TerminalLocal(TerminalOp::NewTab {
755 command: None,
756 })];
757 }
758 Key::Char('w') | Key::Char('W') => {
759 if let Some(id) = self.active_id() {
760 return vec![Operation::TerminalLocal(TerminalOp::CloseTab { id })];
761 }
762 return vec![];
763 }
764 Key::Char('r') | Key::Char('R') => {
765 if let Some(id) = self.active_id() {
766 return vec![Operation::TerminalLocal(TerminalOp::OpenMenu { id })];
767 }
768 return vec![];
769 }
770 Key::Char('f') | Key::Char('F') => {
771 return vec![Operation::SearchLocal(crate::operation::SearchOp::Open { replace: false })];
772 }
773 _ => {}
774 }
775 }
776
777 if alt {
778 match key.key {
779 Key::ArrowLeft => return vec![Operation::TerminalLocal(TerminalOp::PrevTab)],
780 Key::ArrowRight => return vec![Operation::TerminalLocal(TerminalOp::NextTab)],
781 _ => {}
782 }
783 }
784
785 if !ctrl && !alt {
789 match key.key {
790 Key::PageUp => {
791 return vec![Operation::NavigatePageUp];
792 }
793 Key::PageDown => {
794 return vec![Operation::NavigatePageDown];
795 }
796 _ => {}
797 }
798 }
799
800 if let Key::F(3) = key.key {
802 if key.modifiers.contains(Modifiers::SHIFT) {
803 return vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)];
804 } else {
805 return vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)];
806 }
807 }
808
809 if let Some(id) = self.active_id()
811 && let Some(data) = Self::key_to_bytes(key)
812 {
813 return vec![
815 Operation::TerminalLocal(TerminalOp::ScrollReset),
816 Operation::TerminalInput { id, data },
817 ];
818 }
819
820 vec![]
821 }
822
823 fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
824 match mouse.kind {
826 MouseEventKind::ScrollUp => {
827 return vec![Operation::NavigatePageUp];
828 }
829 MouseEventKind::ScrollDown => {
830 return vec![Operation::NavigatePageDown];
831 }
832 _ => {}
833 }
834
835 let is_down = matches!(mouse.kind, MouseEventKind::Down(_));
837
838 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
840 use crate::commands::CommandId;
841 return vec![Operation::OpenContextMenu {
842 items: vec![
843 ("New Tab".to_string(), CommandId::new_static("terminal", "new_tab"), Some(true)),
844 ("Rename Tab".to_string(), CommandId::new_static("terminal", "rename_active_tab"), Some(true)),
845 ("Close Tab".to_string(), CommandId::new_static("terminal", "close_active_tab"), Some(true)),
846 ("Clear".to_string(), CommandId::new_static("terminal", "clear_active_tab"), Some(true)),
847 ],
848 x: mouse.column,
849 y: mouse.row,
850 }];
851 }
852
853 if let Some(TabPopup::Menu { .. }) = &self.popup {
855 let menu = Menu::new(MENU_ITEMS);
856 let menu_area = Rect {
857 x: 0,
858 y: 1,
859 width: 18,
860 height: (MENU_ITEMS.len() + 2) as u16,
861 };
862
863 if let Some(clicked_item) = menu.hit_test((mouse.column, mouse.row), menu_area) {
864 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
865 let current_cursor = if let Some(TabPopup::Menu { cursor, .. }) = &self.popup {
866 *cursor
867 } else {
868 0
869 };
870 let mut ops: Vec<Operation> = vec![];
871 if clicked_item < current_cursor {
872 for _ in 0..(current_cursor - clicked_item) {
873 ops.push(Operation::NavigateUp);
874 }
875 } else {
876 for _ in 0..(clicked_item - current_cursor) {
877 ops.push(Operation::NavigateDown);
878 }
879 }
880 ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
881 return ops;
882 }
883 return vec![];
884 }
885
886 if is_down {
888 return vec![Operation::TerminalLocal(TerminalOp::CloseMenu)];
889 }
890 return vec![];
891 }
892
893 if let Some(TabPopup::Selector { cursor }) = &self.popup {
895 let n = self.tabs.len();
896 let (cols, rows) = self.last_size.get();
897 let view_rows = rows.saturating_sub(1).max(1);
900 let width = self
901 .tabs
902 .iter()
903 .map(|t| t.title.chars().count())
904 .max()
905 .unwrap_or(10) as u16
906 + 4;
907 let width = width.min(cols).max(14);
908 let height = (n as u16 + 2).min(view_rows);
909 let y = view_rows.saturating_sub(height);
910 let selector_area = Rect { x: 1, y, width, height };
911
912 if let Some(clicked_item) = Menu::new(&self.tabs.iter().map(|t| t.title.as_str()).collect::<Vec<_>>()).hit_test((mouse.column, mouse.row), selector_area) {
913 if is_down {
914 let current = *cursor;
915 let mut ops: Vec<Operation> = vec![];
916 if clicked_item < current {
917 for _ in 0..(current - clicked_item) { ops.push(Operation::NavigateUp); }
918 } else {
919 for _ in 0..(clicked_item - current) { ops.push(Operation::NavigateDown); }
920 }
921 ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
922 return ops;
923 }
924 return vec![];
925 }
926 if is_down {
927 return vec![Operation::TerminalLocal(TerminalOp::CloseMenu)];
928 }
929 }
930
931 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
933 && let Some(tab) = self.tabs.get(self.active) {
934 let col = mouse.column;
935 let row = mouse.row;
936 if let Some(link) = tab.links.iter().find(|l| l.row == row && col >= l.start_col && col < l.end_col) {
937 match &link.kind {
938 crate::widgets::terminal::LinkKind::Url(u) => {
939 return vec![Operation::OpenUrl { url: u.clone() }];
940 }
941 crate::widgets::terminal::LinkKind::File { path, line, column } => {
942 let mut ops = vec![Operation::OpenFile { path: path.clone() }];
943 if let Some(l) = line {
944 let row_idx = l.saturating_sub(1);
946 let col_idx = column.unwrap_or(1).saturating_sub(1);
947 ops.push(Operation::MoveCursor {
948 path: Some(path.clone()),
949 cursor: crate::editor::Position::new(row_idx, col_idx),
950 });
951 }
952 return ops;
953 }
954 crate::widgets::terminal::LinkKind::Diagnostic { .. } => {}
958 crate::widgets::terminal::LinkKind::Search | crate::widgets::terminal::LinkKind::SearchCurrent => {}
959 }
960 }
961 }
962
963 vec![]
964 }
965
966 fn handle_operation(&mut self, op: &Operation, settings: &Settings) -> Option<Event> {
967 match op {
968 Operation::TerminalLocal(TerminalOp::Output { id, data }) => {
970 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
971 tab.parser.borrow_mut().process(data);
972 tab.scroll_offset = 0;
974 tab.links = Self::detect_links_for_tab(tab);
976 }
977 Some(Event::applied("terminal", op.clone()))
978 }
979
980 Operation::TerminalLocal(TerminalOp::AppendScrollback { id, lines }) => {
982 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
983 tab.scrollback_lines.extend(lines.iter().cloned());
984 }
985 Some(Event::applied("terminal", op.clone()))
986 }
987
988 Operation::TerminalLocal(TerminalOp::ProcessExited { id }) => {
990 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
991 tab.exited = true;
992 }
993 Some(Event::applied("terminal", op.clone()))
995 }
996
997 Operation::TerminalLocal(TerminalOp::SwitchToTab { index }) => {
999 if *index < self.tabs.len() {
1000 self.active = *index;
1001 }
1002 Some(Event::applied("terminal", op.clone()))
1003 }
1004 Operation::TerminalLocal(TerminalOp::NextTab) => {
1005 if self.tabs.len() > 1 {
1006 self.active = (self.active + 1) % self.tabs.len();
1007 }
1008 Some(Event::applied("terminal", op.clone()))
1009 }
1010 Operation::TerminalLocal(TerminalOp::PrevTab) => {
1011 if self.tabs.len() > 1 {
1012 self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
1013 }
1014 Some(Event::applied("terminal", op.clone()))
1015 }
1016
1017 Operation::NavigatePageUp => {
1020 if let Some(tab) = self.tabs.get_mut(self.active) {
1021 let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1022 let max_scrollback = tab.scrollback_len as u16;
1023 tab.scroll_offset = (tab.scroll_offset + lines).min(max_scrollback);
1024 }
1025 Some(Event::applied("terminal", op.clone()))
1026 }
1027 Operation::NavigatePageDown => {
1028 if let Some(tab) = self.tabs.get_mut(self.active) {
1029 let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1030 tab.scroll_offset = tab.scroll_offset.saturating_sub(lines);
1031 }
1032 Some(Event::applied("terminal", op.clone()))
1033 }
1034 Operation::TerminalLocal(TerminalOp::ScrollReset) => {
1035 if let Some(tab) = self.tabs.get_mut(self.active) {
1036 tab.scroll_offset = 0;
1037 }
1038 Some(Event::applied("terminal", op.clone()))
1039 }
1040
1041 Operation::TerminalLocal(TerminalOp::OpenMenu { id }) => {
1043 self.popup = Some(TabPopup::Menu { id: *id, cursor: 0 });
1044 Some(Event::applied("terminal", op.clone()))
1045 }
1046 Operation::TerminalLocal(TerminalOp::OpenTabSelector) => {
1048 self.popup = Some(TabPopup::Selector { cursor: self.active });
1049 Some(Event::applied("terminal", op.clone()))
1050 }
1051 Operation::TerminalLocal(TerminalOp::OpenRename { id }) => {
1053 let current = self
1054 .tabs
1055 .iter()
1056 .find(|t| t.id == *id)
1057 .map(|t| t.title.clone())
1058 .unwrap_or_default();
1059 let mut field = InputField::new("Rename");
1060 field.set_text(current);
1061 self.popup = Some(TabPopup::Rename { id: *id, input: field });
1062 Some(Event::applied("terminal", op.clone()))
1063 }
1064 Operation::TerminalLocal(TerminalOp::ClearTab { id }) => {
1066 if let Some(idx) = self.tabs.iter().position(|t| t.id == *id) {
1067 self.write_input(*id, b"\x0c");
1070 if let Some(tab) = self.tabs.get_mut(idx) {
1071 tab.scroll_offset = 0;
1072 }
1073 }
1074 Some(Event::applied("terminal", op.clone()))
1075 }
1076 Operation::NavigateUp => {
1077 match &mut self.popup {
1078 Some(TabPopup::Menu { cursor, .. }) if *cursor > 0 => {
1079 *cursor -= 1;
1080 }
1081 Some(TabPopup::Selector { cursor }) if *cursor > 0 => {
1082 *cursor -= 1;
1083 }
1084 _ => {}
1085 }
1086 Some(Event::applied("terminal", op.clone()))
1087 }
1088 Operation::NavigateDown => {
1089 match &mut self.popup {
1090 Some(TabPopup::Menu { cursor, .. }) => {
1091 *cursor = (*cursor + 1).min(MENU_ITEMS.len() - 1);
1092 }
1093 Some(TabPopup::Selector { cursor }) => {
1094 *cursor = (*cursor + 1).min(self.tabs.len().saturating_sub(1));
1095 }
1096 _ => {}
1097 }
1098 Some(Event::applied("terminal", op.clone()))
1099 }
1100 Operation::TerminalLocal(TerminalOp::CloseMenu) => {
1101 self.popup = None;
1102 Some(Event::applied("terminal", op.clone()))
1103 }
1104 Operation::TerminalLocal(TerminalOp::MenuConfirm) => {
1105 match self.popup.take() {
1106 Some(TabPopup::Menu { id, cursor }) => match cursor {
1107 0 => {
1108 let current = self
1110 .tabs
1111 .iter()
1112 .find(|t| t.id == id)
1113 .map(|t| t.title.clone())
1114 .unwrap_or_default();
1115 let mut field = InputField::new("Rename");
1116 field.set_text(current);
1117 self.popup = Some(TabPopup::Rename { id, input: field });
1118 }
1119 _ => {
1120 self.close_tab_by_id(id);
1122 }
1123 },
1124 Some(TabPopup::Selector { cursor }) => {
1125 self.active = cursor.min(self.tabs.len().saturating_sub(1));
1127 }
1128 _ => {
1129 self.popup = None;
1130 }
1131 }
1132 Some(Event::applied("terminal", op.clone()))
1133 }
1134
1135 Operation::TerminalLocal(TerminalOp::RenameChanged(s)) => {
1137 if let Some(TabPopup::Rename { input, .. }) = &mut self.popup {
1138 input.set_text(s.clone());
1139 }
1140 Some(Event::applied("terminal", op.clone()))
1141 }
1142 Operation::TerminalLocal(TerminalOp::RenameConfirm) => {
1143 if let Some(TabPopup::Rename { id, input }) = self.popup.take()
1144 && let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
1145 && !input.is_empty()
1146 {
1147 tab.title = input.text().to_owned();
1148 }
1149 Some(Event::applied("terminal", op.clone()))
1150 }
1151 Operation::TerminalLocal(TerminalOp::RenameCancel) => {
1152 self.popup = None;
1153 Some(Event::applied("terminal", op.clone()))
1154 }
1155 Operation::SearchLocal(sop) => {
1157 use crate::operation::SearchOp;
1158 fn scroll_to_match(
1160 match_line: usize,
1161 tab: &mut TerminalTab,
1162 _rows: usize,
1163 ) {
1164 let total = tab.scrollback_lines.len();
1169 let top_index = if match_line <= total { match_line } else { total };
1170 tab.scroll_offset = total.saturating_sub(top_index) as u16;
1171 tab.links = TerminalView::detect_links_for_tab(tab);
1172 }
1173 let rows = self.last_size.get().1.saturating_sub(1).max(1) as usize;
1174 match sop {
1175 SearchOp::Open { .. } => {
1176 let mut field = InputField::new("Search");
1177 let (prev_text, prev_opts) = self.last_search.as_ref()
1179 .map(|s| (s.query.text().to_owned(), s.opts.clone()))
1180 .unwrap_or_else(|| (String::new(), crate::views::editor::SearchOptions::default()));
1181 field.set_text(prev_text.clone());
1182 let opts = prev_opts;
1183 let matches = if let Some(tab) = self.tabs.get(self.active) {
1184 let mut all_lines = tab.scrollback_lines.clone();
1188 let parser_ref = tab.parser.borrow();
1189 let screen = parser_ref.screen();
1190 let (rows, cols) = screen.size();
1191 for r in 0..rows {
1192 let mut row_text = String::with_capacity(cols as usize);
1193 for c in 0..cols {
1194 if let Some(cell) = screen.cell(r, c) {
1195 let s = cell.contents();
1196 row_text.push_str(if s.is_empty() { " " } else { &s });
1197 } else {
1198 row_text.push(' ');
1199 }
1200 }
1201 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1202 }
1203 find_matches_styled(&all_lines, &prev_text, &opts)
1204 } else { Vec::new() };
1205 self.search = Some(TerminalSearch { query: field, opts, matches, current: None });
1206 }
1207 SearchOp::Close => {
1208 self.last_search = self.search.take();
1210 }
1211 SearchOp::QueryInput(field_op) => {
1212 if let Some(s) = &mut self.search {
1213 s.query.apply(field_op);
1214 if let Some(tab) = self.tabs.get(self.active) {
1215 let q = s.query.text().to_owned();
1216 let mut all_lines = tab.scrollback_lines.clone();
1218 let parser_ref = tab.parser.borrow();
1219 let screen = parser_ref.screen();
1220 let (rows, cols) = screen.size();
1221 for r in 0..rows {
1222 let mut row_text = String::with_capacity(cols as usize);
1223 for c in 0..cols {
1224 if let Some(cell) = screen.cell(r, c) {
1225 let s_cell = cell.contents();
1226 row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1227 } else {
1228 row_text.push(' ');
1229 }
1230 }
1231 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1232 }
1233 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1234 s.current = None;
1235 log::debug!("terminal: QueryInput q='{}' matches_total={}", q, s.matches.len());
1236 }
1237 }
1238 }
1239 SearchOp::ToggleIgnoreCase => {
1240 if let Some(s) = &mut self.search {
1241 s.opts.ignore_case = !s.opts.ignore_case;
1242 if let Some(tab) = self.tabs.get(self.active) {
1243 let q = s.query.text().to_owned();
1244 let mut all_lines = tab.scrollback_lines.clone();
1246 let parser_ref = tab.parser.borrow();
1247 let screen = parser_ref.screen();
1248 let (rows, cols) = screen.size();
1249 for r in 0..rows {
1250 let mut row_text = String::with_capacity(cols as usize);
1251 for c in 0..cols {
1252 if let Some(cell) = screen.cell(r, c) {
1253 let s_cell = cell.contents();
1254 row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1255 } else {
1256 row_text.push(' ');
1257 }
1258 }
1259 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1260 }
1261 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1262 s.current = None;
1263 log::debug!("terminal: ToggleIgnoreCase q='{}' matches_total={}", q, s.matches.len());
1264 }
1265 }
1266 }
1267 SearchOp::ToggleRegex => {
1268 if let Some(s) = &mut self.search {
1269 s.opts.regex = !s.opts.regex;
1270 if let Some(tab) = self.tabs.get(self.active) {
1271 let q = s.query.text().to_owned();
1272 let mut all_lines = tab.scrollback_lines.clone();
1273 let parser_ref = tab.parser.borrow();
1274 let screen = parser_ref.screen();
1275 let (rows, cols) = screen.size();
1276 for r in 0..rows {
1277 let mut row_text = String::with_capacity(cols as usize);
1278 for c in 0..cols {
1279 if let Some(cell) = screen.cell(r, c) {
1280 let s_cell = cell.contents();
1281 row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1282 } else {
1283 row_text.push(' ');
1284 }
1285 }
1286 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1287 }
1288 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1289 s.current = None;
1290 log::debug!("terminal: ToggleRegex q='{}' matches_total={}", q, s.matches.len());
1291 }
1292 }
1293 }
1294 SearchOp::ToggleSmartCase => {
1295 if let Some(s) = &mut self.search {
1296 s.opts.smart_case = !s.opts.smart_case;
1297 if let Some(tab) = self.tabs.get(self.active) {
1298 let q = s.query.text().to_owned();
1299 let mut all_lines = tab.scrollback_lines.clone();
1300 let parser_ref = tab.parser.borrow();
1301 let screen = parser_ref.screen();
1302 let (rows, cols) = screen.size();
1303 for r in 0..rows {
1304 let mut row_text = String::with_capacity(cols as usize);
1305 for c in 0..cols {
1306 if let Some(cell) = screen.cell(r, c) {
1307 let s_cell = cell.contents();
1308 row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
1309 } else {
1310 row_text.push(' ');
1311 }
1312 }
1313 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1314 }
1315 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1316 s.current = None;
1317 log::debug!("terminal: ToggleSmartCase q='{}' matches_total={}", q, s.matches.len());
1318 }
1319 }
1320 }
1321 SearchOp::NextMatch => {
1322 if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1324 let next = match s.current { Some(i) => (i + 1) % s.matches.len(), None => 0 };
1325 s.current = Some(next);
1326 let (match_line, _, _) = s.matches[next];
1327 log::debug!("terminal: NextMatch idx={} total={} match_line={}", next, s.matches.len(), match_line);
1328 if let Some(tab) = self.tabs.get_mut(self.active) {
1329 scroll_to_match(match_line, tab, rows);
1330 }
1331 }
1332 }
1333 SearchOp::PrevMatch => {
1334 if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1335 let prev = match s.current {
1336 Some(0) | None => s.matches.len().saturating_sub(1),
1337 Some(i) => i - 1,
1338 };
1339 s.current = Some(prev);
1340 let (match_line, _, _) = s.matches[prev];
1341 log::debug!("terminal: PrevMatch idx={} total={} match_line={}", prev, s.matches.len(), match_line);
1342 if let Some(tab) = self.tabs.get_mut(self.active) {
1343 scroll_to_match(match_line, tab, rows);
1344 }
1345 }
1346 }
1347 _ => {}
1348 }
1349 Some(Event::applied("terminal", op.clone()))
1350 }
1351
1352 Operation::TerminalLocal(TerminalOp::CloseTab { id }) => {
1354 self.close_tab_by_id(*id);
1355 Some(Event::applied("terminal", op.clone()))
1356 }
1357
1358 Operation::TerminalLocal(TerminalOp::Resize { cols, rows }) => {
1360 self.last_size.set((*cols, *rows));
1361 self.update_from_settings(settings);
1362 self.resize_all(*rows, *cols);
1363 Some(Event::applied("terminal", op.clone()))
1364 }
1365
1366 _ => None,
1367 }
1368 }
1369
1370 fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
1371 if self.last_size.get() == (0, 0) && area.width > 0 && area.height > 0 {
1375 self.last_size.set((area.width, area.height));
1376 }
1377 log::debug!(
1378 "TerminalView::render: area={}x{} last_size={:?}",
1379 area.width,
1380 area.height,
1381 self.last_size.get()
1382 );
1383
1384 let search_active = self.search.is_some();
1386 let (content_area, bar_area) = if search_active && area.height > 1 {
1387 let bar = Rect { y: area.y, height: 1, ..area };
1388 let content = Rect { y: area.y + 1, height: area.height - 1, ..area };
1389 (content, Some(bar))
1390 } else {
1391 (area, None)
1392 };
1393
1394 if let Some(tab) = self.tabs.get(self.active) {
1396 {
1397 let mut parser = tab.parser.borrow_mut();
1398 parser.set_scrollback(tab.scroll_offset as usize);
1399 }
1400 let parser_ref = tab.parser.borrow();
1402 let mut links = tab.links.clone();
1403 let active_search = self.search.as_ref().or(self.last_search.as_ref());
1404 if let Some(search) = active_search {
1405 log::debug!("terminal.render: query='{}' matches_total={} current={:?} existing_links={}", search.query.text(), search.matches.len(), search.current, tab.links.len());
1406 let q = search.query.text();
1407 if !q.is_empty() {
1408 let screen = parser_ref.screen();
1409 let (rows, cols) = screen.size();
1410 let ignore = search.opts.ignore_case
1411 || (search.opts.smart_case && !q.chars().any(|c| c.is_uppercase()));
1412 if search.opts.regex {
1413 if let Ok(re) = regex::RegexBuilder::new(q).case_insensitive(ignore).build() {
1414 for r in 0..rows {
1415 let mut row_text = String::with_capacity(cols as usize);
1416 for c in 0..cols {
1417 if let Some(cell) = screen.cell(r, c) {
1418 let s = cell.contents();
1419 row_text.push_str(if s.is_empty() { " " } else { &s });
1420 } else {
1421 row_text.push(' ');
1422 }
1423 }
1424 for m in re.find_iter(&row_text) {
1425 links.push(crate::widgets::terminal::Link {
1426 kind: crate::widgets::terminal::LinkKind::Search,
1427 row: r,
1428 start_col: m.start() as u16,
1429 end_col: m.end() as u16,
1430 text: row_text[m.start()..m.end()].to_string(),
1431 });
1432 }
1433 }
1434 }
1435 } else {
1436 let q_cmp = if ignore { q.to_lowercase() } else { q.to_owned() };
1437 for r in 0..rows {
1438 let mut row_text = String::with_capacity(cols as usize);
1439 for c in 0..cols {
1440 if let Some(cell) = screen.cell(r, c) {
1441 let s = cell.contents();
1442 row_text.push_str(if s.is_empty() { " " } else { &s });
1443 } else {
1444 row_text.push(' ');
1445 }
1446 }
1447 let l = if ignore {row_text.to_lowercase() } else { row_text.clone() };
1448 let mut start = 0usize;
1449 while let Some(found) = l[start..].find(&q_cmp) {
1450 let abs = start + found;
1451 let end = abs + q.len();
1452 links.push(crate::widgets::terminal::Link {
1453 kind: crate::widgets::terminal::LinkKind::Search,
1454 row: r,
1455 start_col: abs as u16,
1456 end_col: end as u16,
1457 text: row_text[abs..end].to_string(),
1458 });
1459 start = end;
1460 }
1461 }
1462 }
1463
1464 if let Some(cur_idx) = search.current && cur_idx < search.matches.len() {
1467 let (match_line, start_col, end_col) = search.matches[cur_idx];
1468 let total = tab.scrollback_lines.len();
1469 let rows_usize = rows as usize;
1470 let top_index = total.saturating_sub(tab.scroll_offset as usize);
1475 if match_line >= top_index && match_line < top_index + rows_usize {
1476 let visible_row = (match_line - top_index) as u16;
1477 let text = if match_line < total {
1480 tab.scrollback_lines.get(match_line)
1481 .and_then(|l| l.text.get(start_col..end_col).map(|s| s.to_string()))
1482 .unwrap_or_default()
1483 } else {
1484 let vis_idx = match_line - total;
1485 let mut row_text = String::with_capacity(cols as usize);
1486 for c in 0..cols {
1487 if let Some(cell) = screen.cell(vis_idx as u16, c) {
1488 let s = cell.contents();
1489 row_text.push_str(if s.is_empty() { " " } else { &s });
1490 } else {
1491 row_text.push(' ');
1492 }
1493 }
1494 row_text.get(start_col..end_col).unwrap_or("").to_string()
1495 };
1496 links.push(crate::widgets::terminal::Link {
1497 kind: crate::widgets::terminal::LinkKind::SearchCurrent,
1498 row: visible_row,
1499 start_col: start_col as u16,
1500 end_col: end_col as u16,
1501 text,
1502 });
1503 }
1504 }
1505 }
1506 }
1507 frame.render_widget(
1508 crate::widgets::terminal::TerminalWidget { parser: parser_ref, links },
1509 content_area,
1510 );
1511 }
1512
1513 if let (Some(bar), Some(search)) = (bar_area, self.search.as_ref()) {
1515 self.render_search_bar(frame, bar, search);
1516 }
1517
1518 let popup_snapshot = match &self.popup {
1520 Some(TabPopup::Menu { id, cursor }) => Some(PopupSnapshot::Menu { id: *id, cursor: *cursor }),
1521 Some(TabPopup::Rename { id, input }) => Some(PopupSnapshot::Rename { id: *id, input: input.text().to_owned() }),
1522 Some(TabPopup::Selector { cursor }) => Some(PopupSnapshot::Selector { cursor: *cursor }),
1523 None => None,
1524 };
1525 if let Some(snap) = popup_snapshot {
1526 match snap {
1527 PopupSnapshot::Menu { id, cursor } => self.render_menu_popup(frame, id, cursor),
1528 PopupSnapshot::Rename { id, input } => self.render_rename_popup(frame, id, &input),
1529 PopupSnapshot::Selector { cursor } => self.render_selector_popup(frame, area, cursor),
1530 }
1531 }
1532 }
1533}
1534
1535impl TerminalView {
1536 fn render_search_bar(&self, frame: &mut Frame, area: Rect, search: &TerminalSearch) {
1538 use ratatui::layout::{Constraint, Direction, Layout};
1539 use ratatui::text::{Line, Span};
1540
1541 let bar_bg = Color::Rgb(30, 45, 70);
1542 let active_bg = Color::Rgb(50, 70, 110);
1543 let btn_on = Style::default().fg(Color::Black).bg(Color::Rgb(80, 170, 220));
1544 let btn_off = Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 55, 80));
1545
1546 const BTN_W: u16 = 27;
1547 let left_w = area.width.saturating_sub(BTN_W);
1548
1549 let btn_row = Line::from(vec![
1550 Span::styled(" ", Style::default().bg(bar_bg)),
1551 Span::styled(" IgnCase ", if search.opts.ignore_case { btn_on } else { btn_off }),
1552 Span::styled(" ", Style::default().bg(bar_bg)),
1553 Span::styled(" Regex ", if search.opts.regex { btn_on } else { btn_off }),
1554 Span::styled(" ", Style::default().bg(bar_bg)),
1555 Span::styled(" Smart ", if search.opts.smart_case { btn_on } else { btn_off }),
1556 Span::styled(" ", Style::default().bg(bar_bg)),
1557 ]);
1558
1559 let count_str = if search.matches.is_empty() {
1560 " No matches".to_owned()
1561 } else {
1562 let cur = search.current.map(|i| i + 1).unwrap_or(0);
1563 format!(" {}/{}", cur, search.matches.len())
1564 };
1565
1566 let query_line = format!(" Find: {} {}", search.query.text(), count_str);
1567
1568 let [left_area, right_area] = Layout::default()
1569 .direction(Direction::Horizontal)
1570 .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
1571 .split(area)[..]
1572 else { return; };
1573
1574 frame.render_widget(
1575 Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(active_bg)),
1576 left_area,
1577 );
1578 frame.render_widget(
1579 Paragraph::new(btn_row).style(Style::default().bg(bar_bg)),
1580 right_area,
1581 );
1582 }
1583}
1584
1585
1586enum PopupSnapshot {
1588 Menu { id: u64, cursor: usize },
1589 Rename { id: u64, input: String },
1590 Selector { cursor: usize },
1591}
1592
1593#[derive(Debug, Default, Serialize, Deserialize)]
1598pub struct TerminalStateStore {
1599 pub tabs: Vec<TerminalTabState>,
1600 pub active: usize,
1601 pub next_id: u64,
1602}
1603
1604#[derive(Debug, Clone, Serialize, Deserialize)]
1605pub struct TerminalTabState {
1606 pub id: u64,
1607 pub title: String,
1608 pub command: String,
1609 pub cwd: PathBuf,
1610}