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 pub show_links: bool,
197 scroll_size: u16,
199}
200
201impl std::fmt::Debug for TerminalView {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.debug_struct("TerminalView")
204 .field("active", &self.active)
205 .field("next_id", &self.next_id)
206 .field("tabs_len", &self.tabs.len())
207 .finish()
208 }
209}
210
211impl Default for TerminalView {
212 fn default() -> Self {
213 Self::new()
214 }
215}
216
217impl TerminalView {
218 pub fn new() -> Self {
219 let size = crossterm::terminal::size().unwrap_or((0, 0));
221 Self {
222 tabs: Vec::new(),
223 active: 0,
224 popup: None,
225 search: None,
226 last_search: None,
227 next_id: 1,
228 last_size: Cell::new(size),
229 show_links: false,
230 scroll_size: DEFAULT_SCROLL_SIZE,
231 }
232 }
233
234 pub fn update_from_settings(&mut self, settings: &Settings) {
237 let size = *terminal::scroll_size(settings);
238 self.scroll_size = size.clamp(1, MAX_SCROLL_SIZE as i64) as u16;
240 }
241
242 fn alloc_id(&mut self) -> u64 {
248 let id = self.next_id;
249 self.next_id += 1;
250 id
251 }
252
253 pub fn spawn_tab(
257 &mut self,
258 command: Option<String>,
259 shell_setting: &str,
260 cwd: &Path,
261 op_tx: &UnboundedSender<Vec<Operation>>,
262 scrollback: usize,
263 ) {
264 let id = self.alloc_id();
265
266 let (cols, rows) = self.last_size.get();
268 let cols = if cols == 0 { 80 } else { cols };
269 let rows = if rows == 0 { 24 } else { rows };
270 let term_rows = rows.saturating_sub(1).max(1);
274 log::debug!(
275 "terminal.spawn_tab: cols={} rows={} term_rows={}",
276 cols,
277 rows,
278 term_rows
279 );
280
281 let shell = if let Some(ref cmd) = command {
283 cmd.clone()
284 } else if !shell_setting.is_empty() {
285 shell_setting.to_string()
286 } else {
287 std::env::var("SHELL").unwrap_or_else(|_| {
288 if cfg!(windows) {
289 std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
290 } else {
291 "/bin/bash".to_string()
292 }
293 })
294 };
295
296 let title = shell
298 .split_whitespace()
299 .next()
300 .and_then(|s| s.rsplit('/').next())
301 .unwrap_or("shell")
302 .to_string();
303
304 let pty_system = native_pty_system();
306 let pair = match pty_system.openpty(PtySize {
307 rows: term_rows,
308 cols,
309 pixel_width: 0,
310 pixel_height: 0,
311 }) {
312 Ok(p) => p,
313 Err(e) => {
314 log::error!("terminal: openpty failed: {e}");
315 return;
316 }
317 };
318
319 let mut cmd = CommandBuilder::new(&shell);
321 cmd.cwd(cwd);
322
323 let child = match pair.slave.spawn_command(cmd) {
324 Ok(c) => c,
325 Err(e) => {
326 log::error!("terminal: spawn_command failed: {e}");
327 return;
328 }
329 };
330 drop(pair.slave);
332
333 let writer = match pair.master.take_writer() {
334 Ok(w) => w,
335 Err(e) => {
336 log::error!("terminal: take_writer failed: {e}");
337 return;
338 }
339 };
340 let reader = match pair.master.try_clone_reader() {
341 Ok(r) => r,
342 Err(e) => {
343 log::error!("terminal: try_clone_reader failed: {e}");
344 return;
345 }
346 };
347
348 let parser = RefCell::new(vt100::Parser::new(term_rows, cols, scrollback));
349
350 self.tabs.push(TerminalTab {
351 id,
352 title,
353 command: shell,
354 cwd: cwd.to_path_buf(),
355 master: pair.master,
356 writer,
357 parser,
358 links: Vec::new(),
359 scroll_offset: 0,
360 scrollback_len: scrollback,
361 exited: false,
362 scrollback_lines: Vec::new(),
363 });
364 self.active = self.tabs.len() - 1;
365
366 let tx = op_tx.clone();
372 let extractor = std::sync::Arc::new(
373 crate::diagnostics_extractor::DiagnosticsExtractor::new(
374 format!("terminal:{id}"),
375 "terminal",
376 ),
377 );
378 let ex = std::sync::Arc::clone(&extractor);
379 tokio::task::spawn_blocking(move || {
380 let _child = child; let mut reader = reader;
382 let mut buf = [0u8; 4096];
383 let mut tp = crate::vt_parser::TerminalParser::new();
385 let mut prev_sev: Option<(crate::issue_registry::Severity, String)> = None;
387
388 loop {
389 match std::io::Read::read(&mut reader, &mut buf) {
390 Ok(0) | Err(_) => {
391 if let Some(line) = tp.flush() {
393 let issues = extract_issues_with_state(&ex, &line, &mut prev_sev);
394 let mut ops =
395 vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
396 id,
397 lines: vec![line],
398 })];
399 ops.extend(issues.into_iter().map(|i| Operation::AddIssue { issue: i }));
400 let _ = tx.send(ops);
401 }
402 let _ =
403 tx.send(vec![Operation::TerminalLocal(TerminalOp::ProcessExited {
404 id,
405 })]);
406 break;
407 }
408 Ok(n) => {
409 let data = buf[..n].to_vec();
410 let _ = tx.send(vec![Operation::TerminalLocal(TerminalOp::Output {
412 id,
413 data: data.clone(),
414 })]);
415 let styled_lines = tp.push(&data);
417 if !styled_lines.is_empty() {
418 let mut issue_ops: Vec<Operation> = Vec::new();
419 for line in &styled_lines {
420 issue_ops.extend(
421 extract_issues_with_state(&ex, line, &mut prev_sev)
422 .into_iter()
423 .map(|i| Operation::AddIssue { issue: i }),
424 );
425 }
426 let mut ops =
427 vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
428 id,
429 lines: styled_lines,
430 })];
431 ops.extend(issue_ops);
432 let _ = tx.send(ops);
433 }
434 }
435 }
436 }
437 });
438 }
439
440 pub fn write_input(&mut self, id: u64, data: &[u8]) {
442 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
443 && let Err(e) = tab.writer.write_all(data)
444 {
445 log::warn!("terminal: write_input failed for tab {id}: {e}");
446 }
447 }
448
449 pub fn resize_all(&mut self, rows: u16, cols: u16) {
451 let term_rows = rows.saturating_sub(1).max(1); for tab in &mut self.tabs {
453 let _ = tab.master.resize(PtySize {
454 rows: term_rows,
455 cols,
456 pixel_width: 0,
457 pixel_height: 0,
458 });
459 tab.parser.borrow_mut().screen_mut().set_size(term_rows, cols);
460 }
461 self.last_size.set((cols, rows));
462 }
463
464 pub(crate) fn active_id(&self) -> Option<u64> {
469 self.tabs.get(self.active).map(|t| t.id)
470 }
471
472 pub fn detect_links_for_tab(tab: &TerminalTab) -> Vec<crate::widgets::terminal::Link> {
473 let mut parser = tab.parser.borrow_mut();
476 parser.screen_mut().set_scrollback(tab.scroll_offset as usize);
477 crate::widgets::terminal::detect_links_from_screen(&parser, &tab.cwd)
478 }
479
480 fn close_tab_by_id(&mut self, id: u64) {
481 if let Some(idx) = self.tabs.iter().position(|t| t.id == id) {
482 self.tabs.remove(idx);
483 if self.active >= self.tabs.len() && !self.tabs.is_empty() {
484 self.active = self.tabs.len() - 1;
485 }
486 }
487 }
488
489 pub fn to_state(&self) -> TerminalStateStore {
494 TerminalStateStore {
495 tabs: self
496 .tabs
497 .iter()
498 .map(|t| TerminalTabState {
499 id: t.id,
500 title: t.title.clone(),
501 command: t.command.clone(),
502 cwd: t.cwd.clone(),
503 })
504 .collect(),
505 active: self.active,
506 next_id: self.next_id,
507 }
508 }
509
510 fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
515 let ctrl = key.modifiers.contains(Modifiers::CTRL);
516 let alt = key.modifiers.contains(Modifiers::ALT);
517
518 match key.key {
519 Key::Char(c) if ctrl => {
520 let lc = c.to_ascii_lowercase();
521 if lc.is_ascii_lowercase() {
522 Some(vec![lc as u8 - b'a' + 1])
523 } else {
524 None
525 }
526 }
527 Key::Char(c) if alt => {
528 Some(vec![0x1b, c as u8])
530 }
531 Key::Char(c) => Some(c.to_string().into_bytes()),
532 Key::Enter => Some(b"\r".to_vec()),
533 Key::Backspace => Some(vec![0x7f]),
534 Key::Tab => Some(b"\t".to_vec()),
535 Key::Delete => Some(b"\x1b[3~".to_vec()),
536 Key::ArrowUp => Some(b"\x1b[A".to_vec()),
537 Key::ArrowDown => Some(b"\x1b[B".to_vec()),
538 Key::ArrowRight => Some(b"\x1b[C".to_vec()),
539 Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
540 Key::Home => Some(b"\x1b[H".to_vec()),
541 Key::End => Some(b"\x1b[F".to_vec()),
542 Key::PageUp => Some(b"\x1b[5~".to_vec()),
543 Key::PageDown => Some(b"\x1b[6~".to_vec()),
544 Key::F(1) => Some(b"\x1bOP".to_vec()),
545 Key::F(2) => Some(b"\x1bOQ".to_vec()),
546 Key::F(3) => Some(b"\x1bOR".to_vec()),
547 Key::F(4) => Some(b"\x1bOS".to_vec()),
548 Key::F(n) => {
549 let code: u8 = match n {
551 5 => 15,
552 6 => 17,
553 7 => 18,
554 8 => 19,
555 9 => 20,
556 10 => 21,
557 11 => 23,
558 12 => 24,
559 _ => return None,
560 };
561 Some(format!("\x1b[{}~", code).into_bytes())
562 }
563 _ => None,
564 }
565 }
566
567 fn render_menu_popup(&self, frame: &mut Frame, _id: u64, cursor: usize) {
572 let area = frame.area();
573 let popup_rect = Rect {
574 x: area.x,
575 y: area.y + 1,
576 width: 18,
577 height: (MENU_ITEMS.len() + 2) as u16,
578 };
579 if popup_rect.bottom() > area.bottom() || popup_rect.right() > area.right() {
580 return;
581 }
582
583 let menu = Menu::new(MENU_ITEMS).cursor(cursor);
584 frame.render_widget(menu, popup_rect);
585 }
586
587 fn render_rename_popup(&self, frame: &mut Frame, _id: u64, input: &str) {
588 let area = frame.area();
589 let popup_rect = Rect {
590 x: area.x,
591 y: area.y + 1,
592 width: 30.min(area.width),
593 height: 3,
594 };
595 if popup_rect.bottom() > area.bottom() {
596 return;
597 }
598 let display = format!(" {}_ ", input);
599 let p = Paragraph::new(display).block(
600 Block::default()
601 .borders(Borders::ALL)
602 .title(" Rename ")
603 .style(
604 Style::default()
605 .fg(Color::Rgb(100, 100, 100))
606 .bg(Color::Rgb(40, 40, 40)),
607 ),
608 );
609 frame.render_widget(p, popup_rect);
610 }
611
612
613 fn render_selector_popup(&self, frame: &mut Frame, area: Rect, cursor: usize) {
614 let n = self.tabs.len();
615 if n == 0 {
616 return;
617 }
618 let height = (n as u16 + 2).min(area.height);
619 let width = self
620 .tabs
621 .iter()
622 .map(|t| t.title.chars().count())
623 .max()
624 .unwrap_or(10) as u16
625 + 4;
626 let width = width.min(area.width).max(14);
627 let y = area.bottom().saturating_sub(height);
629 let popup_rect = Rect { x: area.x + 1, y, width, height };
630 let tab_names: Vec<&str> = self.tabs.iter().map(|t| t.title.as_str()).collect();
631 let menu = Menu::new(&tab_names).cursor(cursor);
632 frame.render_widget(menu, popup_rect);
633 }
634}
635
636impl TerminalView {
641 pub fn handle_paste(&self, text: &str) -> Vec<Operation> {
643 let Some(id) = self.active_id() else {
644 return vec![];
645 };
646 let data = text.as_bytes().to_vec();
647 vec![
648 Operation::TerminalLocal(TerminalOp::ScrollReset),
649 Operation::TerminalInput { id, data },
650 ]
651 }
652}
653
654impl View for TerminalView {
659 const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
660
661 fn save_state(&mut self, app: &mut crate::app_state::AppState) {
662 crate::views::save_state::terminal_pre_save(self, app);
663 }
664
665 fn status_bar(
666 &self,
667 _state: &crate::app_state::AppState,
668 bar: &mut crate::widgets::status_bar::StatusBarBuilder,
669 ) {
670 let name = self
671 .tabs
672 .get(self.active)
673 .map(|t| t.title.as_str())
674 .unwrap_or("(no tab)");
675 bar.menu(
676 format!("tab: {}", name),
677 crate::commands::CommandId::new_static("terminal", "open_tab_selector"),
678 );
679 }
680
681 fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
682 if let Some(TabPopup::Rename { input, .. }) = &self.popup {
684 return match key.key {
685 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::RenameCancel)],
686 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::RenameConfirm)],
687 _ => {
688 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
689 let mut f = input.clone();
690 f.apply(&field_op);
691 vec![Operation::TerminalLocal(TerminalOp::RenameChanged(
692 f.text().to_owned(),
693 ))]
694 } else {
695 vec![]
696 }
697 }
698 };
699 }
700
701 if let Some(TabPopup::Selector { .. }) = &self.popup {
703 return match key.key {
704 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
705 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
706 Key::ArrowUp => vec![Operation::NavigateUp],
707 Key::ArrowDown => vec![Operation::NavigateDown],
708 _ => vec![],
709 };
710 }
711
712 if let Some(TabPopup::Menu { .. }) = &self.popup {
714 return match key.key {
715 Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
716 Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
717 Key::ArrowUp => vec![Operation::NavigateUp],
718 Key::ArrowDown => vec![Operation::NavigateDown],
719 _ => vec![],
720 };
721 }
722
723 if self.search.is_some() {
725 let alt = key.modifiers.contains(Modifiers::ALT);
726 return match (alt, key.key) {
727 (_, Key::Escape) => vec![Operation::SearchLocal(crate::operation::SearchOp::Close)],
728 (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
729 vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)]
730 }
731 (_, Key::F(3)) => {
732 vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)]
733 }
734 (true, Key::Char('c')) | (true, Key::Char('C')) => {
735 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleIgnoreCase)]
736 }
737 (true, Key::Char('r')) | (true, Key::Char('R')) => {
738 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleRegex)]
739 }
740 (true, Key::Char('s')) | (true, Key::Char('S')) => {
741 vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleSmartCase)]
742 }
743 _ => {
744 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
745 vec![Operation::SearchLocal(crate::operation::SearchOp::QueryInput(field_op))]
746 } else {
747 vec![]
748 }
749 }
750 };
751 }
752
753 let ctrl = key.modifiers.contains(Modifiers::CTRL);
755 let alt = key.modifiers.contains(Modifiers::ALT);
756
757 if ctrl && !alt {
759 match key.key {
760 Key::Char('t') | Key::Char('T') => {
761 return vec![Operation::TerminalLocal(TerminalOp::NewTab {
762 command: None,
763 })];
764 }
765 Key::Char('w') | Key::Char('W') => {
766 if let Some(id) = self.active_id() {
767 return vec![Operation::TerminalLocal(TerminalOp::CloseTab { id })];
768 }
769 return vec![];
770 }
771 Key::Char('r') | Key::Char('R') => {
772 if let Some(id) = self.active_id() {
773 return vec![Operation::TerminalLocal(TerminalOp::OpenMenu { id })];
774 }
775 return vec![];
776 }
777 Key::Char('f') | Key::Char('F') => {
778 return vec![Operation::SearchLocal(crate::operation::SearchOp::Open { replace: false })];
779 }
780 _ => {}
781 }
782 }
783
784 if alt {
785 match key.key {
786 Key::ArrowLeft => return vec![Operation::TerminalLocal(TerminalOp::PrevTab)],
787 Key::ArrowRight => return vec![Operation::TerminalLocal(TerminalOp::NextTab)],
788 _ => {}
789 }
790 }
791
792 if !ctrl && !alt {
796 match key.key {
797 Key::PageUp => {
798 return vec![Operation::NavigatePageUp];
799 }
800 Key::PageDown => {
801 return vec![Operation::NavigatePageDown];
802 }
803 _ => {}
804 }
805 }
806
807 if let Key::F(3) = key.key {
809 if key.modifiers.contains(Modifiers::SHIFT) {
810 return vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)];
811 } else {
812 return vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)];
813 }
814 }
815
816 if let Some(id) = self.active_id()
818 && let Some(data) = Self::key_to_bytes(key)
819 {
820 if let Key::Enter = key.key {
826 return vec![
827 Operation::TerminalLocal(TerminalOp::ScrollReset),
828 Operation::ClearIssuesByMarker { marker: format!("terminal:{}", id) },
829 Operation::TerminalInput { id, data },
830 ];
831 }
832 return vec![
833 Operation::TerminalLocal(TerminalOp::ScrollReset),
834 Operation::TerminalInput { id, data },
835 ];
836 }
837
838 vec![]
839 }
840
841 fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
842 let highlight = mouse.modifiers.contains(Modifiers::CTRL);
846 let mut ops: Vec<Operation> = vec![Operation::TerminalLocal(TerminalOp::SetLinkHighlight { enabled: highlight })];
847
848 match mouse.kind {
850 MouseEventKind::ScrollUp => {
851 ops.push(Operation::NavigatePageUp);
852 return ops;
853 }
854 MouseEventKind::ScrollDown => {
855 ops.push(Operation::NavigatePageDown);
856 return ops;
857 }
858 _ => {}
859 }
860
861 let is_down = matches!(mouse.kind, MouseEventKind::Down(_));
863
864 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
866 use crate::commands::CommandId;
867 ops.push(Operation::OpenContextMenu {
868 items: vec![
869 ("New Tab".to_string(), CommandId::new_static("terminal", "new_tab"), Some(true)),
870 ("Rename Tab".to_string(), CommandId::new_static("terminal", "rename_active_tab"), Some(true)),
871 ("Close Tab".to_string(), CommandId::new_static("terminal", "close_active_tab"), Some(true)),
872 ("Clear".to_string(), CommandId::new_static("terminal", "clear_active_tab"), Some(true)),
873 ],
874 x: mouse.column,
875 y: mouse.row,
876 });
877 return ops;
878 }
879
880 if let Some(TabPopup::Menu { .. }) = &self.popup {
882 let menu = Menu::new(MENU_ITEMS);
883 let menu_area = Rect {
884 x: 0,
885 y: 1,
886 width: 18,
887 height: (MENU_ITEMS.len() + 2) as u16,
888 };
889
890 if let Some(clicked_item) = menu.hit_test((mouse.column, mouse.row), menu_area) {
891 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
892 let current_cursor = if let Some(TabPopup::Menu { cursor, .. }) = &self.popup {
893 *cursor
894 } else {
895 0
896 };
897 let mut inner_ops: Vec<Operation> = vec![];
898 if clicked_item < current_cursor {
899 for _ in 0..(current_cursor - clicked_item) {
900 inner_ops.push(Operation::NavigateUp);
901 }
902 } else {
903 for _ in 0..(clicked_item - current_cursor) {
904 inner_ops.push(Operation::NavigateDown);
905 }
906 }
907 inner_ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
908 ops.extend(inner_ops);
909 return ops;
910 }
911 return ops;
912 }
913
914 if is_down {
916 ops.push(Operation::TerminalLocal(TerminalOp::CloseMenu));
917 return ops;
918 }
919 return ops;
920 }
921
922 if let Some(TabPopup::Selector { cursor }) = &self.popup {
924 let n = self.tabs.len();
925 let (cols, rows) = self.last_size.get();
926 let view_rows = rows.saturating_sub(1).max(1);
929 let width = self
930 .tabs
931 .iter()
932 .map(|t| t.title.chars().count())
933 .max()
934 .unwrap_or(10) as u16
935 + 4;
936 let width = width.min(cols).max(14);
937 let height = (n as u16 + 2).min(view_rows);
938 let y = view_rows.saturating_sub(height);
939 let selector_area = Rect { x: 1, y, width, height };
940
941 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) {
942 if is_down {
943 let current = *cursor;
944 let mut inner_ops: Vec<Operation> = vec![];
945 if clicked_item < current { for _ in 0..(current - clicked_item) { inner_ops.push(Operation::NavigateUp); } } else { for _ in 0..(clicked_item - current) { inner_ops.push(Operation::NavigateDown); } }
946 inner_ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
947 ops.extend(inner_ops);
948 return ops;
949 }
950 return ops;
951 }
952 if is_down {
953 ops.push(Operation::TerminalLocal(TerminalOp::CloseMenu));
954 return ops;
955 }
956 }
957
958 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
960 && let Some(tab) = self.tabs.get(self.active) {
961 let col = mouse.column;
962 let row = mouse.row;
963 if let Some(link) = tab.links.iter().find(|l| l.row == row && col >= l.start_col && col < l.end_col) {
964 match &link.kind {
965 crate::widgets::terminal::LinkKind::Url(u) => {
966 ops.push(Operation::OpenUrl { url: u.clone() });
967 return ops;
968 }
969 crate::widgets::terminal::LinkKind::File { path, line, column } => {
970 ops.push(Operation::OpenFile { path: path.clone() });
971 if let Some(l) = line {
972 let row_idx = l.saturating_sub(1);
974 let col_idx = column.unwrap_or(1).saturating_sub(1);
975 ops.push(Operation::GoToLineLocal(
976 crate::operation::GoToLineOp::JumpTo {
977 line: row_idx,
978 column: col_idx,
979 },
980 ));
981 }
982 return ops;
983 }
984 crate::widgets::terminal::LinkKind::Diagnostic { .. } => {}
988 crate::widgets::terminal::LinkKind::Search | crate::widgets::terminal::LinkKind::SearchCurrent => {}
989 }
990 }
991 }
992
993 ops
994 }
995
996 fn handle_operation(&mut self, op: &Operation, settings: &Settings) -> Option<Event> {
997 match op {
998 Operation::TerminalLocal(TerminalOp::Output { id, data }) => {
1000 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
1001 tab.parser.borrow_mut().process(data);
1002 tab.scroll_offset = 0;
1004 if self.show_links {
1006 tab.links = Self::detect_links_for_tab(tab);
1007 } else {
1008 tab.links.clear();
1009 }
1010 }
1011 Some(Event::applied("terminal", op.clone()))
1012 }
1013
1014 Operation::TerminalLocal(TerminalOp::AppendScrollback { id, lines }) => {
1016 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
1017 tab.scrollback_lines.extend(lines.iter().cloned());
1018 }
1019 Some(Event::applied("terminal", op.clone()))
1020 }
1021
1022 Operation::TerminalLocal(TerminalOp::ProcessExited { id }) => {
1024 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
1025 tab.exited = true;
1026 }
1027 Some(Event::applied("terminal", op.clone()))
1029 }
1030
1031 Operation::TerminalLocal(TerminalOp::SwitchToTab { index }) => {
1033 if *index < self.tabs.len() {
1034 self.active = *index;
1035 }
1036 Some(Event::applied("terminal", op.clone()))
1037 }
1038 Operation::TerminalLocal(TerminalOp::NextTab) => {
1039 if self.tabs.len() > 1 {
1040 self.active = (self.active + 1) % self.tabs.len();
1041 }
1042 Some(Event::applied("terminal", op.clone()))
1043 }
1044 Operation::TerminalLocal(TerminalOp::PrevTab) => {
1045 if self.tabs.len() > 1 {
1046 self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
1047 }
1048 Some(Event::applied("terminal", op.clone()))
1049 }
1050
1051 Operation::NavigatePageUp => {
1054 if let Some(tab) = self.tabs.get_mut(self.active) {
1055 let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1056 let max_scrollback = tab.scrollback_len as u16;
1057 tab.scroll_offset = (tab.scroll_offset + lines).min(max_scrollback);
1058 if self.show_links {
1061 tab.links = Self::detect_links_for_tab(tab);
1062 } else {
1063 tab.links.clear();
1064 }
1065 }
1066 Some(Event::applied("terminal", op.clone()))
1067 }
1068 Operation::NavigatePageDown => {
1069 if let Some(tab) = self.tabs.get_mut(self.active) {
1070 let lines = self.scroll_size.min(MAX_SCROLL_STEP);
1071 tab.scroll_offset = tab.scroll_offset.saturating_sub(lines);
1072 if self.show_links {
1075 tab.links = Self::detect_links_for_tab(tab);
1076 } else {
1077 tab.links.clear();
1078 }
1079 }
1080 Some(Event::applied("terminal", op.clone()))
1081 }
1082 Operation::TerminalLocal(TerminalOp::ScrollReset) => {
1083 if let Some(tab) = self.tabs.get_mut(self.active) {
1084 tab.scroll_offset = 0;
1085 if self.show_links {
1088 tab.links = Self::detect_links_for_tab(tab);
1089 } else {
1090 tab.links.clear();
1091 }
1092 }
1093 Some(Event::applied("terminal", op.clone()))
1094 }
1095
1096 Operation::TerminalLocal(TerminalOp::SetLinkHighlight { enabled }) => {
1098 self.show_links = *enabled;
1100 if let Some(tab) = self.tabs.get_mut(self.active) {
1101 if *enabled {
1102 tab.links = TerminalView::detect_links_for_tab(tab);
1103 } else {
1104 tab.links.clear();
1105 }
1106 }
1107 Some(Event::applied("terminal", op.clone()))
1108 }
1109 Operation::TerminalLocal(TerminalOp::OpenMenu { id }) => {
1110 self.popup = Some(TabPopup::Menu { id: *id, cursor: 0 });
1111 Some(Event::applied("terminal", op.clone()))
1112 }
1113 Operation::TerminalLocal(TerminalOp::OpenTabSelector) => {
1115 self.popup = Some(TabPopup::Selector { cursor: self.active });
1116 Some(Event::applied("terminal", op.clone()))
1117 }
1118 Operation::TerminalLocal(TerminalOp::OpenRename { id }) => {
1120 let current = self
1121 .tabs
1122 .iter()
1123 .find(|t| t.id == *id)
1124 .map(|t| t.title.clone())
1125 .unwrap_or_default();
1126 let mut field = InputField::new("Rename");
1127 field.set_text(current);
1128 self.popup = Some(TabPopup::Rename { id: *id, input: field });
1129 Some(Event::applied("terminal", op.clone()))
1130 }
1131 Operation::TerminalLocal(TerminalOp::ClearTab { id }) => {
1133 if let Some(idx) = self.tabs.iter().position(|t| t.id == *id) {
1134 self.write_input(*id, b"\x0c");
1137 if let Some(tab) = self.tabs.get_mut(idx) {
1138 tab.scroll_offset = 0;
1139 }
1140 }
1141 Some(Event::applied("terminal", op.clone()))
1142 }
1143 Operation::NavigateUp => {
1144 match &mut self.popup {
1145 Some(TabPopup::Menu { cursor, .. }) if *cursor > 0 => {
1146 *cursor -= 1;
1147 }
1148 Some(TabPopup::Selector { cursor }) if *cursor > 0 => {
1149 *cursor -= 1;
1150 }
1151 _ => {}
1152 }
1153 Some(Event::applied("terminal", op.clone()))
1154 }
1155 Operation::NavigateDown => {
1156 match &mut self.popup {
1157 Some(TabPopup::Menu { cursor, .. }) => {
1158 *cursor = (*cursor + 1).min(MENU_ITEMS.len() - 1);
1159 }
1160 Some(TabPopup::Selector { cursor }) => {
1161 *cursor = (*cursor + 1).min(self.tabs.len().saturating_sub(1));
1162 }
1163 _ => {}
1164 }
1165 Some(Event::applied("terminal", op.clone()))
1166 }
1167 Operation::TerminalLocal(TerminalOp::CloseMenu) => {
1168 self.popup = None;
1169 Some(Event::applied("terminal", op.clone()))
1170 }
1171 Operation::TerminalLocal(TerminalOp::MenuConfirm) => {
1172 match self.popup.take() {
1173 Some(TabPopup::Menu { id, cursor }) => match cursor {
1174 0 => {
1175 let current = self
1177 .tabs
1178 .iter()
1179 .find(|t| t.id == id)
1180 .map(|t| t.title.clone())
1181 .unwrap_or_default();
1182 let mut field = InputField::new("Rename");
1183 field.set_text(current);
1184 self.popup = Some(TabPopup::Rename { id, input: field });
1185 }
1186 _ => {
1187 self.close_tab_by_id(id);
1189 }
1190 },
1191 Some(TabPopup::Selector { cursor }) => {
1192 self.active = cursor.min(self.tabs.len().saturating_sub(1));
1194 }
1195 _ => {
1196 self.popup = None;
1197 }
1198 }
1199 Some(Event::applied("terminal", op.clone()))
1200 }
1201
1202 Operation::TerminalLocal(TerminalOp::RenameChanged(s)) => {
1204 if let Some(TabPopup::Rename { input, .. }) = &mut self.popup {
1205 input.set_text(s.clone());
1206 }
1207 Some(Event::applied("terminal", op.clone()))
1208 }
1209 Operation::TerminalLocal(TerminalOp::RenameConfirm) => {
1210 if let Some(TabPopup::Rename { id, input }) = self.popup.take()
1211 && let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
1212 && !input.is_empty()
1213 {
1214 tab.title = input.text().to_owned();
1215 }
1216 Some(Event::applied("terminal", op.clone()))
1217 }
1218 Operation::TerminalLocal(TerminalOp::RenameCancel) => {
1219 self.popup = None;
1220 Some(Event::applied("terminal", op.clone()))
1221 }
1222 Operation::SearchLocal(sop) => {
1224 use crate::operation::SearchOp;
1225 fn scroll_to_match(
1227 match_line: usize,
1228 tab: &mut TerminalTab,
1229 _rows: usize,
1230 show_links: bool,
1231 ) {
1232 let total = tab.scrollback_lines.len();
1237 let top_index = if match_line <= total { match_line } else { total };
1238 tab.scroll_offset = total.saturating_sub(top_index) as u16;
1239 if show_links {
1240 tab.links = TerminalView::detect_links_for_tab(tab);
1241 } else {
1242 tab.links.clear();
1243 }
1244 }
1245 let rows = self.last_size.get().1.saturating_sub(1).max(1) as usize;
1246 match sop {
1247 SearchOp::Open { .. } => {
1248 let mut field = InputField::new("Search");
1249 let (prev_text, prev_opts) = self.last_search.as_ref()
1251 .map(|s| (s.query.text().to_owned(), s.opts.clone()))
1252 .unwrap_or_else(|| (String::new(), crate::views::editor::SearchOptions::default()));
1253 field.set_text(prev_text.clone());
1254 let opts = prev_opts;
1255 let matches = if let Some(tab) = self.tabs.get(self.active) {
1256 let mut all_lines = tab.scrollback_lines.clone();
1260 let parser_ref = tab.parser.borrow();
1261 let screen = parser_ref.screen();
1262 let (rows, cols) = screen.size();
1263 for r in 0..rows {
1264 let mut row_text = String::with_capacity(cols as usize);
1265 for c in 0..cols {
1266 if let Some(cell) = screen.cell(r, c) {
1267 let s = cell.contents();
1268 row_text.push_str(if s.is_empty() { " " } else { s });
1269 } else {
1270 row_text.push(' ');
1271 }
1272 }
1273 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1274 }
1275 find_matches_styled(&all_lines, &prev_text, &opts)
1276 } else { Vec::new() };
1277 self.search = Some(TerminalSearch { query: field, opts, matches, current: None });
1278 }
1279 SearchOp::Close => {
1280 self.last_search = self.search.take();
1282 }
1283 SearchOp::QueryInput(field_op) => {
1284 if let Some(s) = &mut self.search {
1285 s.query.apply(field_op);
1286 if let Some(tab) = self.tabs.get(self.active) {
1287 let q = s.query.text().to_owned();
1288 let mut all_lines = tab.scrollback_lines.clone();
1290 let parser_ref = tab.parser.borrow();
1291 let screen = parser_ref.screen();
1292 let (rows, cols) = screen.size();
1293 for r in 0..rows {
1294 let mut row_text = String::with_capacity(cols as usize);
1295 for c in 0..cols {
1296 if let Some(cell) = screen.cell(r, c) {
1297 let s_cell = cell.contents();
1298 row_text.push_str(if s_cell.is_empty() { " " } else { s_cell });
1299 } else {
1300 row_text.push(' ');
1301 }
1302 }
1303 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1304 }
1305 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1306 s.current = None;
1307 log::debug!("terminal: QueryInput q='{}' matches_total={}", q, s.matches.len());
1308 }
1309 }
1310 }
1311 SearchOp::ToggleIgnoreCase => {
1312 if let Some(s) = &mut self.search {
1313 s.opts.ignore_case = !s.opts.ignore_case;
1314 if let Some(tab) = self.tabs.get(self.active) {
1315 let q = s.query.text().to_owned();
1316 let mut all_lines = tab.scrollback_lines.clone();
1318 let parser_ref = tab.parser.borrow();
1319 let screen = parser_ref.screen();
1320 let (rows, cols) = screen.size();
1321 for r in 0..rows {
1322 let mut row_text = String::with_capacity(cols as usize);
1323 for c in 0..cols {
1324 if let Some(cell) = screen.cell(r, c) {
1325 let s_cell = cell.contents();
1326 row_text.push_str(if s_cell.is_empty() { " " } else { s_cell });
1327 } else {
1328 row_text.push(' ');
1329 }
1330 }
1331 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1332 }
1333 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1334 s.current = None;
1335 log::debug!("terminal: ToggleIgnoreCase q='{}' matches_total={}", q, s.matches.len());
1336 }
1337 }
1338 }
1339 SearchOp::ToggleRegex => {
1340 if let Some(s) = &mut self.search {
1341 s.opts.regex = !s.opts.regex;
1342 if let Some(tab) = self.tabs.get(self.active) {
1343 let q = s.query.text().to_owned();
1344 let mut all_lines = tab.scrollback_lines.clone();
1345 let parser_ref = tab.parser.borrow();
1346 let screen = parser_ref.screen();
1347 let (rows, cols) = screen.size();
1348 for r in 0..rows {
1349 let mut row_text = String::with_capacity(cols as usize);
1350 for c in 0..cols {
1351 if let Some(cell) = screen.cell(r, c) {
1352 let s_cell = cell.contents();
1353 row_text.push_str(if s_cell.is_empty() { " " } else { s_cell });
1354 } else {
1355 row_text.push(' ');
1356 }
1357 }
1358 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1359 }
1360 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1361 s.current = None;
1362 log::debug!("terminal: ToggleRegex q='{}' matches_total={}", q, s.matches.len());
1363 }
1364 }
1365 }
1366 SearchOp::ToggleSmartCase => {
1367 if let Some(s) = &mut self.search {
1368 s.opts.smart_case = !s.opts.smart_case;
1369 if let Some(tab) = self.tabs.get(self.active) {
1370 let q = s.query.text().to_owned();
1371 let mut all_lines = tab.scrollback_lines.clone();
1372 let parser_ref = tab.parser.borrow();
1373 let screen = parser_ref.screen();
1374 let (rows, cols) = screen.size();
1375 for r in 0..rows {
1376 let mut row_text = String::with_capacity(cols as usize);
1377 for c in 0..cols {
1378 if let Some(cell) = screen.cell(r, c) {
1379 let s_cell = cell.contents();
1380 row_text.push_str(if s_cell.is_empty() { " " } else { s_cell });
1381 } else {
1382 row_text.push(' ');
1383 }
1384 }
1385 all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
1386 }
1387 s.matches = find_matches_styled(&all_lines, &q, &s.opts);
1388 s.current = None;
1389 log::debug!("terminal: ToggleSmartCase q='{}' matches_total={}", q, s.matches.len());
1390 }
1391 }
1392 }
1393 SearchOp::NextMatch => {
1394 if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1396 let next = match s.current { Some(i) => (i + 1) % s.matches.len(), None => 0 };
1397 s.current = Some(next);
1398 let (match_line, _, _) = s.matches[next];
1399 log::debug!("terminal: NextMatch idx={} total={} match_line={}", next, s.matches.len(), match_line);
1400 if let Some(tab) = self.tabs.get_mut(self.active) {
1401 scroll_to_match(match_line, tab, rows, self.show_links);
1402 }
1403 }
1404 }
1405 SearchOp::PrevMatch => {
1406 if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
1407 let prev = match s.current {
1408 Some(0) | None => s.matches.len().saturating_sub(1),
1409 Some(i) => i - 1,
1410 };
1411 s.current = Some(prev);
1412 let (match_line, _, _) = s.matches[prev];
1413 log::debug!("terminal: PrevMatch idx={} total={} match_line={}", prev, s.matches.len(), match_line);
1414 if let Some(tab) = self.tabs.get_mut(self.active) {
1415 scroll_to_match(match_line, tab, rows, self.show_links);
1416 }
1417 }
1418 }
1419 _ => {}
1420 }
1421 Some(Event::applied("terminal", op.clone()))
1422 }
1423
1424 Operation::TerminalLocal(TerminalOp::CloseTab { id }) => {
1426 self.close_tab_by_id(*id);
1427 Some(Event::applied("terminal", op.clone()))
1428 }
1429
1430 Operation::TerminalLocal(TerminalOp::Resize { cols, rows }) => {
1432 self.last_size.set((*cols, *rows));
1433 self.update_from_settings(settings);
1434 self.resize_all(*rows, *cols);
1435 Some(Event::applied("terminal", op.clone()))
1436 }
1437
1438 _ => None,
1439 }
1440 }
1441
1442 fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
1443 if self.last_size.get() == (0, 0) && area.width > 0 && area.height > 0 {
1447 self.last_size.set((area.width, area.height));
1448 }
1449 log::debug!(
1450 "TerminalView::render: area={}x{} last_size={:?}",
1451 area.width,
1452 area.height,
1453 self.last_size.get()
1454 );
1455
1456 let search_active = self.search.is_some();
1458 let (content_area, bar_area) = if search_active && area.height > 1 {
1459 let bar = Rect { y: area.y, height: 1, ..area };
1460 let content = Rect { y: area.y + 1, height: area.height - 1, ..area };
1461 (content, Some(bar))
1462 } else {
1463 (area, None)
1464 };
1465
1466 if let Some(tab) = self.tabs.get(self.active) {
1468 {
1469 let mut parser = tab.parser.borrow_mut();
1470 parser.screen_mut().set_scrollback(tab.scroll_offset as usize);
1471 }
1472 let parser_ref = tab.parser.borrow();
1474 let mut links = tab.links.clone();
1475 let active_search = self.search.as_ref().or(self.last_search.as_ref());
1476 if let Some(search) = active_search {
1477 log::debug!("terminal.render: query='{}' matches_total={} current={:?} existing_links={}", search.query.text(), search.matches.len(), search.current, tab.links.len());
1478 let q = search.query.text();
1479 if !q.is_empty() {
1480 let screen = parser_ref.screen();
1481 let (rows, cols) = screen.size();
1482 let ignore = search.opts.ignore_case
1483 || (search.opts.smart_case && !q.chars().any(|c| c.is_uppercase()));
1484 if search.opts.regex {
1485 if let Ok(re) = regex::RegexBuilder::new(q).case_insensitive(ignore).build() {
1486 for r in 0..rows {
1487 let mut row_text = String::with_capacity(cols as usize);
1488 for c in 0..cols {
1489 if let Some(cell) = screen.cell(r, c) {
1490 let s = cell.contents();
1491 row_text.push_str(if s.is_empty() { " " } else { s });
1492 } else {
1493 row_text.push(' ');
1494 }
1495 }
1496 for m in re.find_iter(&row_text) {
1497 links.push(crate::widgets::terminal::Link {
1498 kind: crate::widgets::terminal::LinkKind::Search,
1499 row: r,
1500 start_col: m.start() as u16,
1501 end_col: m.end() as u16,
1502 text: row_text[m.start()..m.end()].to_string(),
1503 });
1504 }
1505 }
1506 }
1507 } else {
1508 let q_cmp = if ignore { q.to_lowercase() } else { q.to_owned() };
1509 for r in 0..rows {
1510 let mut row_text = String::with_capacity(cols as usize);
1511 for c in 0..cols {
1512 if let Some(cell) = screen.cell(r, c) {
1513 let s = cell.contents();
1514 row_text.push_str(if s.is_empty() { " " } else { s });
1515 } else {
1516 row_text.push(' ');
1517 }
1518 }
1519 let l = if ignore {row_text.to_lowercase() } else { row_text.clone() };
1520 let mut start = 0usize;
1521 while let Some(found) = l[start..].find(&q_cmp) {
1522 let abs = start + found;
1523 let end = abs + q.len();
1524 links.push(crate::widgets::terminal::Link {
1525 kind: crate::widgets::terminal::LinkKind::Search,
1526 row: r,
1527 start_col: abs as u16,
1528 end_col: end as u16,
1529 text: row_text[abs..end].to_string(),
1530 });
1531 start = end;
1532 }
1533 }
1534 }
1535
1536 if let Some(cur_idx) = search.current && cur_idx < search.matches.len() {
1539 let (match_line, start_byte, end_byte) = search.matches[cur_idx];
1540 let total = tab.scrollback_lines.len();
1541 let rows_usize = rows as usize;
1542 let top_index = total.saturating_sub(tab.scroll_offset as usize);
1547 if match_line >= top_index && match_line < top_index + rows_usize {
1548 let visible_row = (match_line - top_index) as u16;
1549 let (text, start_col_vis, end_col_vis) = if match_line < total {
1552 let l = &tab.scrollback_lines[match_line];
1553 let text = l.text.get(start_byte..end_byte).unwrap_or("").to_string();
1554 let start_col = crate::widgets::text_area::byte_to_screen_col(&l.text, start_byte, 8);
1555 let end_col = crate::widgets::text_area::byte_to_screen_col(&l.text, end_byte, 8);
1556 (text, start_col as u16, end_col as u16)
1557 } else {
1558 let vis_idx = match_line - total;
1559 let mut row_text = String::with_capacity(cols as usize);
1560 for c in 0..cols {
1561 if let Some(cell) = screen.cell(vis_idx as u16, c) {
1562 let s = cell.contents();
1563 row_text.push_str(if s.is_empty() { " " } else { s });
1564 } else {
1565 row_text.push(' ');
1566 }
1567 }
1568 let text = row_text.get(start_byte..end_byte).unwrap_or("").to_string();
1569 let start_col = crate::widgets::text_area::byte_to_screen_col(&row_text, start_byte, 8);
1570 let end_col = crate::widgets::text_area::byte_to_screen_col(&row_text, end_byte, 8);
1571 (text, start_col as u16, end_col as u16)
1572 };
1573 links.push(crate::widgets::terminal::Link {
1574 kind: crate::widgets::terminal::LinkKind::SearchCurrent,
1575 row: visible_row,
1576 start_col: start_col_vis,
1577 end_col: end_col_vis,
1578 text,
1579 });
1580 }
1581 }
1582 }
1583 }
1584 frame.render_widget(
1585 crate::widgets::terminal::TerminalWidget { parser: parser_ref, links, show_links: self.show_links },
1586 content_area,
1587 );
1588 }
1589
1590 if let (Some(bar), Some(search)) = (bar_area, self.search.as_ref()) {
1592 self.render_search_bar(frame, bar, search);
1593 }
1594
1595 let popup_snapshot = match &self.popup {
1597 Some(TabPopup::Menu { id, cursor }) => Some(PopupSnapshot::Menu { id: *id, cursor: *cursor }),
1598 Some(TabPopup::Rename { id, input }) => Some(PopupSnapshot::Rename { id: *id, input: input.text().to_owned() }),
1599 Some(TabPopup::Selector { cursor }) => Some(PopupSnapshot::Selector { cursor: *cursor }),
1600 None => None,
1601 };
1602 if let Some(snap) = popup_snapshot {
1603 match snap {
1604 PopupSnapshot::Menu { id, cursor } => self.render_menu_popup(frame, id, cursor),
1605 PopupSnapshot::Rename { id, input } => self.render_rename_popup(frame, id, &input),
1606 PopupSnapshot::Selector { cursor } => self.render_selector_popup(frame, area, cursor),
1607 }
1608 }
1609 }
1610}
1611
1612impl TerminalView {
1613 fn render_search_bar(&self, frame: &mut Frame, area: Rect, search: &TerminalSearch) {
1615 use ratatui::layout::{Constraint, Direction, Layout};
1616 use ratatui::text::{Line, Span};
1617
1618 let bar_bg = Color::Rgb(30, 45, 70);
1619 let active_bg = Color::Rgb(50, 70, 110);
1620 let btn_on = Style::default().fg(Color::Black).bg(Color::Rgb(80, 170, 220));
1621 let btn_off = Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 55, 80));
1622
1623 const BTN_W: u16 = 27;
1624 let left_w = area.width.saturating_sub(BTN_W);
1625
1626 let btn_row = Line::from(vec![
1627 Span::styled(" ", Style::default().bg(bar_bg)),
1628 Span::styled(" IgnCase ", if search.opts.ignore_case { btn_on } else { btn_off }),
1629 Span::styled(" ", Style::default().bg(bar_bg)),
1630 Span::styled(" Regex ", if search.opts.regex { btn_on } else { btn_off }),
1631 Span::styled(" ", Style::default().bg(bar_bg)),
1632 Span::styled(" Smart ", if search.opts.smart_case { btn_on } else { btn_off }),
1633 Span::styled(" ", Style::default().bg(bar_bg)),
1634 ]);
1635
1636 let count_str = if search.matches.is_empty() {
1637 " No matches".to_owned()
1638 } else {
1639 let cur = search.current.map(|i| i + 1).unwrap_or(0);
1640 format!(" {}/{}", cur, search.matches.len())
1641 };
1642
1643 let query_line = format!(" Find: {} {}", search.query.text(), count_str);
1644
1645 let [left_area, right_area] = Layout::default()
1646 .direction(Direction::Horizontal)
1647 .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
1648 .split(area)[..]
1649 else { return; };
1650
1651 frame.render_widget(
1652 Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(active_bg)),
1653 left_area,
1654 );
1655 frame.render_widget(
1656 Paragraph::new(btn_row).style(Style::default().bg(bar_bg)),
1657 right_area,
1658 );
1659 }
1660}
1661
1662
1663enum PopupSnapshot {
1665 Menu { id: u64, cursor: usize },
1666 Rename { id: u64, input: String },
1667 Selector { cursor: usize },
1668}
1669
1670#[derive(Debug, Default, Serialize, Deserialize)]
1675pub struct TerminalStateStore {
1676 pub tabs: Vec<TerminalTabState>,
1677 pub active: usize,
1678 pub next_id: u64,
1679}
1680
1681#[derive(Debug, Clone, Serialize, Deserialize)]
1682pub struct TerminalTabState {
1683 pub id: u64,
1684 pub title: String,
1685 pub command: String,
1686 pub cwd: PathBuf,
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use super::*;
1692 use crate::input::{Key, KeyEvent, Modifiers};
1693 use tokio::sync::mpsc;
1694
1695 #[tokio::test]
1698 async fn pressing_enter_emits_clear_issues_marker() {
1699 let mut tv = TerminalView::new();
1700 let (_tx, _rx) = mpsc::unbounded_channel::<Vec<Operation>>();
1701 let cwd = std::path::Path::new(".");
1702
1703 let pty_system = portable_pty::native_pty_system();
1705 let pair = pty_system
1706 .openpty(portable_pty::PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 })
1707 .expect("openpty");
1708 let writer = pair.master.take_writer().expect("take_writer");
1709 let parser = std::cell::RefCell::new(vt100::Parser::new(24, 80, 10));
1710 let tab = TerminalTab {
1711 id: tv.alloc_id(),
1712 title: "test".to_string(),
1713 command: "".to_string(),
1714 cwd: cwd.to_path_buf(),
1715 master: pair.master,
1716 writer,
1717 parser,
1718 links: Vec::new(),
1719 scroll_offset: 0,
1720 scrollback_len: 10,
1721 exited: false,
1722 scrollback_lines: Vec::new(),
1723 };
1724 tv.tabs.push(tab);
1725 tv.active = tv.tabs.len() - 1;
1726 assert!(!tv.tabs.is_empty(), "a tab should be present");
1727
1728 let id = tv.tabs[tv.active].id;
1729 let key = KeyEvent { modifiers: Modifiers::empty(), key: Key::Enter };
1730 let ops = tv.handle_key(key);
1731
1732 let found = ops.into_iter().any(|op| match op {
1733 Operation::ClearIssuesByMarker { marker } => marker == format!("terminal:{}", id),
1734 _ => false,
1735 });
1736
1737 assert!(found, "expected ClearIssuesByMarker for marker terminal:{}", id);
1738 }
1739}