1use crate::intercept::{
2 parse_request_text, parse_response_text, serialize_request, serialize_response,
3 InterceptId, InterceptedItem, Verdict,
4};
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
6use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
7use ratatui::layout::{Constraint, Direction, Layout};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
11use ratatui::Terminal;
12use std::io;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::mpsc;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18struct PendingItem {
19 item: InterceptedItem,
20 received_at: Instant,
21}
22
23struct HistoryEntry {
24 id: InterceptId,
25 method: String,
26 uri: String,
27 status: Option<u16>,
28 verdict: String,
29 _detail: String,
30}
31
32enum Mode {
33 Normal,
34 Editing {
35 content: Vec<String>,
36 original_text: String,
37 cursor: (usize, usize),
38 scroll: usize,
39 },
40}
41
42struct TuiApp {
43 rx: mpsc::Receiver<InterceptedItem>,
44 active: Arc<AtomicBool>,
45 pending: Option<PendingItem>,
46 history: Vec<HistoryEntry>,
47 history_state: ListState,
48 mode: Mode,
49 detail_scroll: u16,
50}
51
52impl TuiApp {
53 fn new(rx: mpsc::Receiver<InterceptedItem>, active: Arc<AtomicBool>) -> Self {
54 Self {
55 rx,
56 active,
57 pending: None,
58 history: Vec::new(),
59 history_state: ListState::default(),
60 mode: Mode::Normal,
61 detail_scroll: 0,
62 }
63 }
64
65 fn poll_intercepted(&mut self) {
66 if self.pending.is_some() {
67 return;
68 }
69 if let Ok(item) = self.rx.try_recv() {
70 self.pending = Some(PendingItem {
71 item,
72 received_at: Instant::now(),
73 });
74 }
75 }
76
77 fn forward_pending(&mut self) {
78 if let Some(pending) = self.pending.take() {
79 match pending.item {
80 InterceptedItem::Request {
81 id,
82 method,
83 uri,
84 headers,
85 body,
86 reply,
87 ..
88 } => {
89 let _ = reply.send(Verdict::Forward {
90 headers: Box::new(headers),
91 body,
92 method: None,
93 uri: None,
94 status: None,
95 });
96 self.history.push(HistoryEntry {
97 id,
98 method: method.to_string(),
99 uri: uri.to_string(),
100 status: None,
101 verdict: "FWD".into(),
102 _detail: String::new(),
103 });
104 }
105 InterceptedItem::Response {
106 id,
107 status,
108 headers,
109 body,
110 reply,
111 ..
112 } => {
113 let _ = reply.send(Verdict::Forward {
114 headers: Box::new(headers),
115 body,
116 method: None,
117 uri: None,
118 status: None,
119 });
120 if let Some(entry) = self.history.iter_mut().rev().find(|e| e.id == id - 1) {
121 entry.status = Some(status.as_u16());
122 }
123 }
124 }
125 self.detail_scroll = 0;
126 }
127 }
128
129 fn drop_pending(&mut self) {
130 if let Some(pending) = self.pending.take() {
131 match pending.item {
132 InterceptedItem::Request {
133 id,
134 method,
135 uri,
136 reply,
137 ..
138 } => {
139 let _ = reply.send(Verdict::Drop);
140 self.history.push(HistoryEntry {
141 id,
142 method: method.to_string(),
143 uri: uri.to_string(),
144 status: None,
145 verdict: "DROP".into(),
146 _detail: String::new(),
147 });
148 }
149 InterceptedItem::Response { reply, .. } => {
150 let _ = reply.send(Verdict::Drop);
151 }
152 }
153 self.detail_scroll = 0;
154 }
155 }
156
157 fn start_edit(&mut self) {
158 if let Some(ref pending) = self.pending {
159 let body = match &pending.item {
161 InterceptedItem::Request { body, .. } => body,
162 InterceptedItem::Response { body, .. } => body,
163 };
164 if !crate::intercept::is_text_body(body) {
165 return; }
167
168 let text = match &pending.item {
169 InterceptedItem::Request {
170 method,
171 uri,
172 version,
173 headers,
174 body,
175 ..
176 } => serialize_request(method, uri, *version, headers, body),
177 InterceptedItem::Response {
178 status,
179 version,
180 headers,
181 body,
182 ..
183 } => serialize_response(*status, *version, headers, body),
184 };
185 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
186 self.mode = Mode::Editing {
187 content: lines,
188 original_text: text,
189 cursor: (0, 0),
190 scroll: 0,
191 };
192 }
193 }
194
195 fn finish_edit(&mut self) {
196 if let Mode::Editing {
197 ref content,
198 ref original_text,
199 ..
200 } = self.mode
201 {
202 let text = content.join("\r\n");
203
204 if text == *original_text {
206 self.mode = Mode::Normal;
207 self.forward_pending();
208 return;
209 }
210 if let Some(pending) = self.pending.take() {
211 match pending.item {
212 InterceptedItem::Request {
213 id, reply, method, uri, ..
214 } => {
215 if let Some((_method, _uri, new_headers, new_body)) =
216 parse_request_text(&text)
217 {
218 let _ = reply.send(Verdict::Forward {
222 headers: Box::new(new_headers),
223 body: new_body,
224 method: None,
225 uri: None,
226 status: None,
227 });
228 } else {
229 let _ = reply.send(Verdict::Drop);
230 }
231 self.history.push(HistoryEntry {
232 id,
233 method: method.to_string(),
234 uri: uri.to_string(),
235 status: None,
236 verdict: "EDIT".into(),
237 _detail: String::new(),
238 });
239 }
240 InterceptedItem::Response {
241 reply, ..
242 } => {
243 if let Some((new_status, new_headers, new_body)) =
244 parse_response_text(&text)
245 {
246 let _ = reply.send(Verdict::Forward {
247 headers: Box::new(new_headers),
248 body: new_body,
249 method: None,
250 uri: None,
251 status: Some(new_status),
252 });
253 } else {
254 let _ = reply.send(Verdict::Drop);
255 }
256 }
257 }
258 }
259 }
260 self.mode = Mode::Normal;
261 self.detail_scroll = 0;
262 }
263
264 fn handle_key(&mut self, key: KeyEvent) -> bool {
265 match &mut self.mode {
266 Mode::Editing {
267 content,
268 cursor,
269 scroll,
270 ..
271 } => {
272 match key.code {
273 KeyCode::Esc => {
274 self.mode = Mode::Normal;
275 }
276 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
277 self.finish_edit();
278 }
279 KeyCode::Char(c) => {
280 if cursor.0 < content.len() {
281 content[cursor.0].insert(cursor.1, c);
282 cursor.1 += 1;
283 }
284 }
285 KeyCode::Backspace => {
286 if cursor.1 > 0 && cursor.0 < content.len() {
287 cursor.1 -= 1;
288 content[cursor.0].remove(cursor.1);
289 } else if cursor.1 == 0 && cursor.0 > 0 {
290 let line = content.remove(cursor.0);
291 cursor.0 -= 1;
292 cursor.1 = content[cursor.0].len();
293 content[cursor.0].push_str(&line);
294 }
295 }
296 KeyCode::Enter => {
297 if cursor.0 < content.len() {
298 let rest = content[cursor.0].split_off(cursor.1);
299 content.insert(cursor.0 + 1, rest);
300 cursor.0 += 1;
301 cursor.1 = 0;
302 }
303 }
304 KeyCode::Left => {
305 if cursor.1 > 0 {
306 cursor.1 -= 1;
307 }
308 }
309 KeyCode::Right => {
310 if cursor.0 < content.len() && cursor.1 < content[cursor.0].len() {
311 cursor.1 += 1;
312 }
313 }
314 KeyCode::Up => {
315 if cursor.0 > 0 {
316 cursor.0 -= 1;
317 cursor.1 = cursor.1.min(content[cursor.0].len());
318 }
319 if *scroll > 0 && cursor.0 < *scroll {
320 *scroll -= 1;
321 }
322 }
323 KeyCode::Down => {
324 if cursor.0 + 1 < content.len() {
325 cursor.0 += 1;
326 cursor.1 = cursor.1.min(content[cursor.0].len());
327 }
328 }
329 _ => {}
330 }
331 false
332 }
333 Mode::Normal => match key.code {
334 KeyCode::Char('q') => true,
335 KeyCode::Char(' ') => {
336 let was = self.active.load(Ordering::Relaxed);
337 self.active.store(!was, Ordering::Relaxed);
338 false
339 }
340 KeyCode::Char('f') => {
341 self.forward_pending();
342 false
343 }
344 KeyCode::Char('d') => {
345 self.drop_pending();
346 false
347 }
348 KeyCode::Char('e') => {
349 self.start_edit();
350 false
351 }
352 KeyCode::Up | KeyCode::Char('k') => {
353 let i = self.history_state.selected().unwrap_or(0);
354 if i > 0 {
355 self.history_state.select(Some(i - 1));
356 }
357 false
358 }
359 KeyCode::Down | KeyCode::Char('j') => {
360 let i = self.history_state.selected().unwrap_or(0);
361 if i + 1 < self.history.len() {
362 self.history_state.select(Some(i + 1));
363 }
364 false
365 }
366 KeyCode::PageUp => {
367 self.detail_scroll = self.detail_scroll.saturating_sub(10);
368 false
369 }
370 KeyCode::PageDown => {
371 self.detail_scroll += 10;
372 false
373 }
374 _ => false,
375 },
376 }
377 }
378
379 fn render(&mut self, frame: &mut ratatui::Frame) {
380 let chunks = Layout::default()
381 .direction(Direction::Vertical)
382 .constraints([
383 Constraint::Length(1),
384 Constraint::Min(5),
385 Constraint::Length(1),
386 ])
387 .split(frame.area());
388
389 let intercept_status = if self.active.load(Ordering::Relaxed) {
391 Span::styled(" INTERCEPT ON ", Style::default().fg(Color::Black).bg(Color::Green))
392 } else {
393 Span::styled(" INTERCEPT OFF ", Style::default().fg(Color::Black).bg(Color::Red))
394 };
395 let pending_count = if self.pending.is_some() { 1 } else { 0 };
396 let status = Line::from(vec![
397 intercept_status,
398 Span::raw(format!(
399 " Pending: {} History: {}",
400 pending_count,
401 self.history.len()
402 )),
403 ]);
404 frame.render_widget(Paragraph::new(status), chunks[0]);
405
406 let main_chunks = Layout::default()
408 .direction(Direction::Horizontal)
409 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
410 .split(chunks[1]);
411
412 let items: Vec<ListItem> = self
414 .history
415 .iter()
416 .enumerate()
417 .map(|(i, e)| {
418 let style = match e.verdict.as_str() {
419 "DROP" => Style::default().fg(Color::Red),
420 "EDIT" => Style::default().fg(Color::Yellow),
421 _ => Style::default().fg(Color::Green),
422 };
423 let status_str = e
424 .status
425 .map(|s| s.to_string())
426 .unwrap_or_else(|| "...".into());
427 ListItem::new(format!(
428 "{:>3} {} {:>3} {} {}",
429 i + 1,
430 e.verdict,
431 status_str,
432 e.method,
433 truncate_uri(&e.uri, 30)
434 ))
435 .style(style)
436 })
437 .collect();
438 let history_list = List::new(items)
439 .block(Block::default().borders(Borders::ALL).title(" History "))
440 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
441 frame.render_stateful_widget(history_list, main_chunks[0], &mut self.history_state);
442
443 match &self.mode {
445 Mode::Editing {
446 content,
447 cursor,
448 scroll,
449 ..
450 } => {
451 let visible: Vec<Line> = content
452 .iter()
453 .skip(*scroll)
454 .enumerate()
455 .map(|(i, line)| {
456 let actual_line = i + scroll;
457 if actual_line == cursor.0 {
458 let mut spans = Vec::new();
460 let col = cursor.1.min(line.len());
461 spans.push(Span::raw(&line[..col]));
462 if col < line.len() {
463 spans.push(Span::styled(
464 &line[col..col + 1],
465 Style::default().bg(Color::White).fg(Color::Black),
466 ));
467 spans.push(Span::raw(&line[col + 1..]));
468 } else {
469 spans.push(Span::styled(
470 " ",
471 Style::default().bg(Color::White).fg(Color::Black),
472 ));
473 }
474 Line::from(spans)
475 } else {
476 Line::from(line.as_str())
477 }
478 })
479 .collect();
480 let editor = Paragraph::new(visible)
481 .block(
482 Block::default()
483 .borders(Borders::ALL)
484 .title(" EDITING [Ctrl+S save | Esc cancel] "),
485 )
486 .wrap(Wrap { trim: false });
487 frame.render_widget(editor, main_chunks[1]);
488 }
489 Mode::Normal => {
490 let detail_text = if let Some(ref pending) = self.pending {
491 let _header = Span::styled(
492 " PENDING ",
493 Style::default().fg(Color::Black).bg(Color::Yellow),
494 );
495 let body = match &pending.item {
496 InterceptedItem::Request {
497 method,
498 uri,
499 version,
500 headers,
501 body,
502 ..
503 } => serialize_request(method, uri, *version, headers, body),
504 InterceptedItem::Response {
505 status,
506 version,
507 headers,
508 body,
509 ..
510 } => serialize_response(*status, *version, headers, body),
511 };
512 let elapsed = pending.received_at.elapsed();
513 format!(
514 "{} (waiting {:.1}s)\n\n{}",
515 if matches!(pending.item, InterceptedItem::Request { .. }) {
516 "REQUEST"
517 } else {
518 "RESPONSE"
519 },
520 elapsed.as_secs_f32(),
521 body
522 )
523 } else {
524 if let Some(idx) = self.history_state.selected() {
526 if let Some(entry) = self.history.get(idx) {
527 format!(
528 "#{} {} {} {}\nVerdict: {}",
529 entry.id, entry.method, entry.uri,
530 entry.status.map(|s| s.to_string()).unwrap_or_default(),
531 entry.verdict
532 )
533 } else {
534 "No selection".into()
535 }
536 } else {
537 "Waiting for requests...".into()
538 }
539 };
540
541 let detail = Paragraph::new(detail_text)
542 .block(Block::default().borders(Borders::ALL).title(" Detail "))
543 .wrap(Wrap { trim: false })
544 .scroll((self.detail_scroll, 0));
545 frame.render_widget(detail, main_chunks[1]);
546 }
547 }
548
549 let help = if matches!(self.mode, Mode::Editing { .. }) {
551 " Ctrl+S: save & forward | Esc: cancel | Arrows: nav | Note: headers+body only, method/URI ignored"
552 } else {
553 " f: forward | d: drop | e: edit | space: toggle | j/k: scroll | q: quit"
554 };
555 frame.render_widget(
556 Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
557 chunks[2],
558 );
559 }
560}
561
562fn truncate_uri(uri: &str, max: usize) -> String {
563 if uri.len() <= max {
564 uri.to_string()
565 } else {
566 format!("{}...", &uri[..max - 3])
567 }
568}
569
570pub fn run_tui(
572 rx: mpsc::Receiver<InterceptedItem>,
573 active: Arc<AtomicBool>,
574) -> io::Result<()> {
575 terminal::enable_raw_mode()?;
576 let mut stdout = io::stdout();
577 crossterm::execute!(stdout, EnterAlternateScreen)?;
578 let backend = ratatui::backend::CrosstermBackend::new(stdout);
579 let mut terminal = Terminal::new(backend)?;
580
581 let mut app = TuiApp::new(rx, active);
582
583 loop {
584 terminal.draw(|f| app.render(f))?;
585
586 if event::poll(Duration::from_millis(100))? {
587 if let Event::Key(key) = event::read()? {
588 if app.handle_key(key) {
589 break;
590 }
591 }
592 }
593
594 app.poll_intercepted();
595 }
596
597 terminal::disable_raw_mode()?;
598 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
599 Ok(())
600}