Skip to main content

rustgate/
tui.rs

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            // Reject editing for binary bodies — would corrupt the payload
160            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; // binary body, cannot edit as text
166            }
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            // No-op edit: if text unchanged, forward with original bytes
205            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                            // Method/URI edits are intentionally ignored —
219                            // the upstream connection is already resolved.
220                            // Only header and body edits are applied.
221                            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        // Status bar
390        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        // Main area
407        let main_chunks = Layout::default()
408            .direction(Direction::Horizontal)
409            .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
410            .split(chunks[1]);
411
412        // History list
413        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        // Right pane
444        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                            // Show cursor position with highlight
459                            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                    // Show selected history entry or empty
525                    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        // Help bar
550        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
570/// Run the TUI on the current thread (should be called from a dedicated OS thread).
571pub 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}