1use ratatui::{
2 layout::{Constraint, Direction, Layout, Margin, Rect},
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{
6 Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Scrollbar,
7 ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap,
8 },
9 Frame,
10};
11
12use crate::app::{App, AppMode, Focus, InputMode, JsonRpcExchange, TransportType};
13
14fn format_json_with_highlighting(json_value: &serde_json::Value) -> Vec<Line<'static>> {
16 let json_str = match serde_json::to_string_pretty(json_value) {
18 Ok(s) => s,
19 Err(_) => return vec![Line::from("Failed to format JSON")],
20 };
21
22 let mut lines = Vec::new();
23
24 for (line_num, line) in json_str.lines().enumerate() {
25 if line_num > 1000 {
27 lines.push(Line::from(Span::styled(
28 "... (content truncated)",
29 Style::default().fg(Color::Gray),
30 )));
31 break;
32 }
33
34 let mut spans = Vec::new();
36 let mut chars = line.chars().peekable();
37 let mut current_token = String::new();
38
39 while let Some(ch) = chars.next() {
40 match ch {
41 '"' => {
42 if !current_token.is_empty() {
44 spans.push(Span::raw(current_token.clone()));
45 current_token.clear();
46 }
47
48 let mut string_content = String::from("\"");
50 for string_ch in chars.by_ref() {
51 string_content.push(string_ch);
52 if string_ch == '"' && !string_content.ends_with("\\\"") {
53 break;
54 }
55 }
56
57 let peek_chars = chars.clone();
59 let mut found_colon = false;
60 for peek_ch in peek_chars {
61 if peek_ch == ':' {
62 found_colon = true;
63 break;
64 } else if !peek_ch.is_whitespace() {
65 break;
66 }
67 }
68
69 if found_colon {
70 spans.push(Span::styled(
72 string_content,
73 Style::default()
74 .fg(Color::Cyan)
75 .add_modifier(Modifier::BOLD),
76 ));
77 } else {
78 spans.push(Span::styled(
80 string_content,
81 Style::default().fg(Color::Green),
82 ));
83 }
84 }
85 ':' => {
86 if !current_token.is_empty() {
87 spans.push(Span::raw(current_token.clone()));
88 current_token.clear();
89 }
90 spans.push(Span::styled(":", Style::default().fg(Color::White)));
91 }
92 ',' => {
93 if !current_token.is_empty() {
94 spans.push(Span::raw(current_token.clone()));
95 current_token.clear();
96 }
97 spans.push(Span::styled(",", Style::default().fg(Color::White)));
98 }
99 '{' | '}' | '[' | ']' => {
100 if !current_token.is_empty() {
101 spans.push(Span::raw(current_token.clone()));
102 current_token.clear();
103 }
104 spans.push(Span::styled(
105 ch.to_string(),
106 Style::default()
107 .fg(Color::Yellow)
108 .add_modifier(Modifier::BOLD),
109 ));
110 }
111 _ => {
112 current_token.push(ch);
114 }
115 }
116 }
117
118 if !current_token.is_empty() {
120 let trimmed_token = current_token.trim();
121 if trimmed_token == "true" || trimmed_token == "false" {
122 spans.push(Span::styled(
123 current_token,
124 Style::default().fg(Color::Magenta),
125 ));
126 } else if trimmed_token == "null" {
127 spans.push(Span::styled(current_token, Style::default().fg(Color::Red)));
128 } else if trimmed_token.parse::<f64>().is_ok() {
129 spans.push(Span::styled(
130 current_token,
131 Style::default().fg(Color::Blue),
132 ));
133 } else {
134 spans.push(Span::raw(current_token));
136 }
137 }
138
139 lines.push(Line::from(spans));
140 }
141
142 lines
143}
144
145fn build_tab_line(
146 labels: &'static [&'static str],
147 selected: usize,
148 is_active: bool,
149 is_enabled: bool,
150) -> Line<'static> {
151 let mut spans = Vec::new();
152
153 for (index, label) in labels.iter().enumerate() {
154 let is_selected = index == selected;
155
156 if is_selected {
157 let mut style = Style::default();
159 if is_enabled {
160 style = style
161 .fg(Color::Black)
162 .bg(if is_active { Color::Cyan } else { Color::White })
163 .add_modifier(Modifier::BOLD);
164 } else {
165 style = style.fg(Color::DarkGray).bg(Color::DarkGray);
166 }
167
168 spans.push(Span::styled(format!(" {} ", *label), style));
169 } else if is_enabled {
170 let style = Style::default()
172 .fg(if is_active { Color::White } else { Color::Gray })
173 .bg(Color::DarkGray);
174 spans.push(Span::styled(format!(" {} ", *label), style));
175 } else {
176 let style = Style::default().fg(Color::DarkGray);
178 spans.push(Span::styled(format!(" {} ", *label), style));
179 }
180
181 if index < labels.len() - 1 {
183 spans.push(Span::raw(""));
184 }
185 }
186
187 Line::from(spans)
188}
189
190pub fn draw(f: &mut Frame, app: &App) {
191 let keybinds = get_keybinds_for_mode(app);
193 let available_width = f.size().width as usize;
194 let line_spans = arrange_keybinds_responsive(keybinds, available_width);
195 let footer_height = (line_spans.len() + 2).max(3); let chunks = Layout::default()
198 .direction(Direction::Vertical)
199 .constraints([
200 Constraint::Length(5), Constraint::Min(10), Constraint::Length(footer_height as u16), Constraint::Length(1), ])
205 .split(f.size());
206
207 draw_header(f, chunks[0], app);
208
209 match app.app_mode {
211 AppMode::Normal => {
212 draw_main_content(f, chunks[1], app);
213 }
214 AppMode::Paused | AppMode::Intercepting => {
215 draw_intercept_content(f, chunks[1], app);
216 }
217 }
218
219 draw_footer(f, chunks[2], app);
220
221 if app.input_mode == InputMode::FilteringRequests {
223 draw_input_dialog(f, app, "Filter Requests", "Filter");
224 }
225}
226
227fn draw_header(f: &mut Frame, area: Rect, app: &App) {
228 let header_chunks = Layout::default()
229 .direction(Direction::Horizontal)
230 .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
231 .split(area);
232
233 draw_request_header(f, header_chunks[0], app);
234 draw_status_header(f, header_chunks[1], app);
235}
236
237fn draw_request_header(f: &mut Frame, area: Rect, app: &App) {
238 let transport_label = match app.proxy_config.transport {
239 TransportType::Http => "HTTP",
240 TransportType::WebSocket => "WebSocket",
241 };
242
243 let transport_style = Style::default()
244 .fg(Color::Black)
245 .bg(Color::Rgb(210, 160, 255))
246 .add_modifier(Modifier::BOLD);
247
248 let dropdown_style = Style::default()
249 .fg(Color::Black)
250 .bg(Color::Rgb(170, 120, 235))
251 .add_modifier(Modifier::BOLD);
252
253 let target_bg = if app.input_mode == InputMode::EditingTarget {
254 Color::Rgb(80, 56, 140)
255 } else {
256 Color::Rgb(48, 36, 96)
257 };
258
259 let target_style = Style::default()
260 .fg(Color::White)
261 .bg(target_bg)
262 .add_modifier(Modifier::BOLD);
263
264 let target_text = if app.input_mode == InputMode::EditingTarget {
265 if app.input_buffer.is_empty() {
266 "Enter target URL".to_string()
267 } else {
268 app.input_buffer.clone()
269 }
270 } else if app.proxy_config.target_url.is_empty() {
271 "Press t to set target".to_string()
272 } else {
273 app.proxy_config.target_url.clone()
274 };
275
276 let mut spans = vec![
277 Span::styled(format!(" {} ", transport_label), transport_style),
278 Span::styled(" ▾ ", dropdown_style),
279 Span::raw(" "),
280 Span::styled(format!(" {} ", target_text), target_style),
281 ];
282
283 if app.input_mode == InputMode::EditingTarget {
284 spans.push(Span::styled("█", target_style));
285 }
286
287 spans.push(Span::raw(" "));
288
289 let filter_bg = if app.input_mode == InputMode::FilteringRequests {
290 Color::Rgb(80, 56, 140)
291 } else {
292 Color::Rgb(48, 36, 96)
293 };
294
295 let filter_style = Style::default()
296 .fg(if app.filter_text.is_empty() {
297 Color::Rgb(180, 170, 210)
298 } else {
299 Color::White
300 })
301 .bg(filter_bg)
302 .add_modifier(Modifier::BOLD);
303
304 let filter_text = if app.filter_text.is_empty() {
305 "Filter (press /)".to_string()
306 } else {
307 format!("Filter: {}", app.filter_text)
308 };
309
310 spans.push(Span::styled(format!(" {} ", filter_text), filter_style));
311
312 if app.input_mode == InputMode::FilteringRequests {
313 spans.push(Span::styled("█", filter_style));
314 }
315
316 let block = Block::default().borders(Borders::ALL).title(Span::styled(
317 "Request",
318 Style::default().fg(Color::LightMagenta),
319 ));
320
321 let paragraph = Paragraph::new(Line::from(spans))
322 .block(block)
323 .wrap(Wrap { trim: true });
324
325 f.render_widget(paragraph, area);
326}
327
328fn draw_status_header(f: &mut Frame, area: Rect, app: &App) {
329 let status_focus = matches!(app.focus, Focus::StatusHeader);
330
331 let inactive_fg = Color::Rgb(180, 170, 210);
332
333 let mut running_style = if app.is_running {
334 Style::default()
335 .fg(Color::Black)
336 .bg(Color::Green)
337 .add_modifier(Modifier::BOLD)
338 } else {
339 Style::default().fg(inactive_fg).bg(Color::Rgb(60, 60, 60))
340 };
341
342 let mut stopped_style = if app.is_running {
343 Style::default().fg(inactive_fg).bg(Color::Rgb(60, 60, 60))
344 } else {
345 Style::default()
346 .fg(Color::White)
347 .bg(Color::Rgb(120, 35, 52))
348 .add_modifier(Modifier::BOLD)
349 };
350
351 if status_focus {
352 if app.is_running {
353 running_style = running_style.add_modifier(Modifier::UNDERLINED);
354 } else {
355 stopped_style = stopped_style.add_modifier(Modifier::UNDERLINED);
356 }
357 }
358
359 let mode_text = match app.app_mode {
360 AppMode::Normal => "Normal".to_string(),
361 AppMode::Paused => "Paused".to_string(),
362 AppMode::Intercepting => format!("Intercepting ({})", app.pending_requests.len()),
363 };
364
365 let mode_color = match app.app_mode {
366 AppMode::Normal => Color::Gray,
367 AppMode::Paused => Color::Yellow,
368 AppMode::Intercepting => Color::Red,
369 };
370
371 let mut lines = Vec::new();
372
373 let tab_spans = vec![
374 Span::styled(" RUNNING ", running_style),
375 Span::styled(" STOPPED ", stopped_style),
376 ];
377 lines.push(Line::from(tab_spans));
378
379 let label_style = Style::default()
380 .fg(Color::Gray)
381 .add_modifier(Modifier::BOLD);
382
383 let info_line = Line::from(vec![
384 Span::styled("Port:", label_style),
385 Span::raw(format!(" {}", app.proxy_config.listen_port)),
386 Span::raw(" "),
387 Span::styled("Mode:", label_style),
388 Span::styled(format!(" {}", mode_text), Style::default().fg(mode_color)),
389 ]);
390 lines.push(info_line);
391
392 if app.input_mode == InputMode::EditingTarget {
393 lines.push(Line::from(Span::styled(
394 "Editing target (Enter to save, Esc to cancel)",
395 Style::default().fg(Color::Yellow),
396 )));
397 }
398
399 let mut block = Block::default().borders(Borders::ALL).title(Span::styled(
400 "Status",
401 Style::default().fg(Color::LightMagenta),
402 ));
403
404 if status_focus {
405 block = block.border_style(
406 Style::default()
407 .fg(Color::Yellow)
408 .add_modifier(Modifier::BOLD),
409 );
410 } else {
411 block = block.border_style(Style::default().fg(Color::DarkGray));
412 }
413
414 let paragraph = Paragraph::new(lines)
415 .block(block)
416 .wrap(Wrap { trim: false });
417
418 f.render_widget(paragraph, area);
419}
420
421fn draw_main_content(f: &mut Frame, area: Rect, app: &App) {
422 let chunks = Layout::default()
423 .direction(Direction::Horizontal)
424 .constraints([
425 Constraint::Percentage(50), Constraint::Percentage(50), ])
428 .split(area);
429
430 draw_message_list(f, chunks[0], app);
431 draw_details_split(f, chunks[1], app);
432}
433
434fn draw_message_list(f: &mut Frame, area: Rect, app: &App) {
435 let filtered: Vec<(usize, &JsonRpcExchange)> = app
436 .exchanges
437 .iter()
438 .enumerate()
439 .filter(|(_, exchange)| {
440 if app.filter_text.is_empty() {
441 true
442 } else {
443 exchange
444 .method
445 .as_deref()
446 .unwrap_or("")
447 .contains(&app.filter_text)
448 }
449 })
450 .collect();
451
452 if filtered.is_empty() {
453 let empty_message = if app.is_running {
454 format!(
455 "Proxy is running on port {}. Waiting for requests...",
456 app.proxy_config.listen_port
457 )
458 } else {
459 "Press 's' to start the proxy and begin capturing messages".to_string()
460 };
461
462 let mut block = Block::default().borders(Borders::ALL).title("Requests");
463 if matches!(app.focus, Focus::MessageList) {
464 block = block.border_style(
465 Style::default()
466 .fg(Color::Yellow)
467 .add_modifier(Modifier::BOLD),
468 );
469 } else {
470 block = block.border_style(Style::default().fg(Color::DarkGray));
471 }
472
473 let paragraph = Paragraph::new(empty_message.as_str())
474 .block(block)
475 .style(Style::default().fg(Color::Gray))
476 .wrap(Wrap { trim: true });
477
478 f.render_widget(paragraph, area);
479 return;
480 }
481
482 let selected_position = filtered
483 .iter()
484 .position(|(index, _)| *index == app.selected_exchange)
485 .unwrap_or(0);
486
487 let highlight_style = if matches!(app.focus, Focus::MessageList) {
488 Style::default()
489 .bg(Color::Cyan)
490 .fg(Color::Black)
491 .add_modifier(Modifier::BOLD)
492 } else {
493 Style::default().fg(Color::White)
494 };
495
496 let header = Row::new(vec![
497 Cell::from("Status"),
498 Cell::from("Transport"),
499 Cell::from("Method"),
500 Cell::from("ID"),
501 Cell::from("Duration"),
502 ])
503 .style(Style::default().add_modifier(Modifier::BOLD))
504 .height(1);
505
506 let rows: Vec<Row> = filtered
507 .iter()
508 .map(|(_, exchange)| {
509 let transport_symbol = match exchange.transport {
510 TransportType::Http => "HTTP",
511 TransportType::WebSocket => "WS",
512 };
513
514 let method = exchange.method.as_deref().unwrap_or("unknown");
515 let id = exchange
516 .id
517 .as_ref()
518 .map(|v| match v {
519 serde_json::Value::String(s) => s.clone(),
520 serde_json::Value::Number(n) => n.to_string(),
521 _ => v.to_string(),
522 })
523 .unwrap_or_else(|| "null".to_string());
524
525 let (status_symbol, status_color) = if exchange.response.is_none() {
526 ("⏳ Pending", Color::Yellow)
527 } else if let Some(response) = &exchange.response {
528 if response.error.is_some() {
529 ("✗ Error", Color::Red)
530 } else {
531 ("✓ Success", Color::Green)
532 }
533 } else {
534 ("? Unknown", Color::Gray)
535 };
536
537 let duration_text =
538 if let (Some(request), Some(response)) = (&exchange.request, &exchange.response) {
539 match response.timestamp.duration_since(request.timestamp) {
540 Ok(duration) => {
541 let millis = duration.as_millis();
542 if millis < 1000 {
543 format!("{}ms", millis)
544 } else {
545 format!("{:.2}s", duration.as_secs_f64())
546 }
547 }
548 Err(_) => "-".to_string(),
549 }
550 } else {
551 "-".to_string()
552 };
553
554 Row::new(vec![
555 Cell::from(status_symbol).style(Style::default().fg(status_color)),
556 Cell::from(transport_symbol).style(Style::default().fg(Color::Blue)),
557 Cell::from(method).style(Style::default().fg(Color::Red)),
558 Cell::from(id).style(Style::default().fg(Color::Gray)),
559 Cell::from(duration_text).style(Style::default().fg(Color::Magenta)),
560 ])
561 .height(1)
562 })
563 .collect();
564
565 let mut table_block = Block::default().borders(Borders::ALL).title("Requests");
566 if matches!(app.focus, Focus::MessageList) {
567 table_block = table_block.border_style(
568 Style::default()
569 .fg(Color::Yellow)
570 .add_modifier(Modifier::BOLD),
571 );
572 } else {
573 table_block = table_block.border_style(Style::default().fg(Color::DarkGray));
574 }
575
576 let table = Table::new(
577 rows,
578 [
579 Constraint::Length(12), Constraint::Length(9), Constraint::Min(15), Constraint::Length(12), Constraint::Length(10), ],
585 )
586 .header(header)
587 .block(table_block)
588 .highlight_style(highlight_style)
589 .highlight_symbol(" ");
590
591 let mut table_state = TableState::default();
592 table_state.select(Some(selected_position));
593 f.render_stateful_widget(table, area, &mut table_state);
594
595 if filtered.len() > 1 {
596 let mut scrollbar_state = ScrollbarState::new(filtered.len()).position(selected_position);
597
598 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
599 .begin_symbol(None)
600 .end_symbol(None)
601 .track_symbol(None)
602 .thumb_symbol("▐");
603
604 f.render_stateful_widget(
605 scrollbar,
606 area.inner(&Margin {
607 vertical: 1,
608 horizontal: 0,
609 }),
610 &mut scrollbar_state,
611 );
612 }
613}
614
615fn draw_request_details(f: &mut Frame, area: Rect, app: &App) {
616 let content = if let Some(exchange) = app.get_selected_exchange() {
617 let mut lines = Vec::new();
618
619 lines.push(Line::from(vec![
621 Span::styled("Transport: ", Style::default().add_modifier(Modifier::BOLD)),
622 Span::raw(format!("{:?}", exchange.transport)),
623 ]));
624
625 if let Some(method) = &exchange.method {
626 lines.push(Line::from(vec![
627 Span::styled("Method: ", Style::default().add_modifier(Modifier::BOLD)),
628 Span::raw(method.clone()),
629 ]));
630 }
631
632 if let Some(id) = &exchange.id {
633 lines.push(Line::from(vec![
634 Span::styled("ID: ", Style::default().add_modifier(Modifier::BOLD)),
635 Span::raw(id.to_string()),
636 ]));
637 }
638
639 lines.push(Line::from(""));
641 lines.push(Line::from(Span::styled(
642 "REQUEST:",
643 Style::default()
644 .add_modifier(Modifier::BOLD)
645 .fg(Color::Green),
646 )));
647 lines.push(build_tab_line(
648 &["Headers", "Body"],
649 app.request_tab,
650 matches!(app.focus, Focus::RequestSection),
651 exchange.request.is_some(),
652 ));
653
654 if let Some(request) = &exchange.request {
655 if app.request_tab == 0 {
656 lines.push(Line::from(""));
658 match &request.headers {
659 Some(headers) if !headers.is_empty() => {
660 for (key, value) in headers {
661 lines.push(Line::from(format!(" {}: {}", key, value)));
662 }
663 }
664 Some(_) => {
665 lines.push(Line::from(" No headers"));
666 }
667 None => {
668 lines.push(Line::from(" No headers captured"));
669 }
670 }
671 } else {
672 lines.push(Line::from(""));
674 let mut request_json = serde_json::Map::new();
675 request_json.insert(
676 "jsonrpc".to_string(),
677 serde_json::Value::String("2.0".to_string()),
678 );
679
680 if let Some(id) = &request.id {
681 request_json.insert("id".to_string(), id.clone());
682 }
683 if let Some(method) = &request.method {
684 request_json.insert(
685 "method".to_string(),
686 serde_json::Value::String(method.clone()),
687 );
688 }
689 if let Some(params) = &request.params {
690 request_json.insert("params".to_string(), params.clone());
691 }
692
693 let request_json_value = serde_json::Value::Object(request_json);
694 let request_json_lines = format_json_with_highlighting(&request_json_value);
695 for line in request_json_lines {
696 lines.push(line);
697 }
698 }
699 } else {
700 lines.push(Line::from(""));
701 lines.push(Line::from("Request not captured yet"));
702 }
703
704 lines
705 } else {
706 vec![Line::from("No request selected")]
707 };
708
709 let inner_area = area.inner(&Margin {
711 vertical: 1,
712 horizontal: 1,
713 });
714 let visible_lines = inner_area.height as usize;
715 let total_lines = content.len();
716
717 let start_line = app.request_details_scroll;
719 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
720 let visible_content = if start_line < total_lines {
721 content[start_line..end_line].to_vec()
722 } else {
723 vec![]
724 };
725
726 let base_title = "Request Details";
728
729 let scroll_info = if total_lines > visible_lines {
730 let progress = ((app.request_details_scroll as f32 / (total_lines - visible_lines) as f32)
731 * 100.0) as u8;
732 format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
733 } else {
734 base_title.to_string()
735 };
736
737 let details_block = if matches!(app.focus, Focus::RequestSection) {
738 Block::default()
739 .borders(Borders::ALL)
740 .title(scroll_info)
741 .border_style(
742 Style::default()
743 .fg(Color::Yellow)
744 .add_modifier(Modifier::BOLD),
745 )
746 } else {
747 Block::default().borders(Borders::ALL).title(scroll_info)
748 };
749
750 let details = Paragraph::new(visible_content)
751 .block(details_block)
752 .wrap(Wrap { trim: false });
753
754 f.render_widget(details, area);
755
756 if total_lines > visible_lines {
757 let mut scrollbar_state =
758 ScrollbarState::new(total_lines).position(app.request_details_scroll);
759
760 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
761 .begin_symbol(None)
762 .end_symbol(None)
763 .track_symbol(None)
764 .thumb_symbol("▐");
765
766 f.render_stateful_widget(
767 scrollbar,
768 area.inner(&Margin {
769 vertical: 1,
770 horizontal: 0,
771 }),
772 &mut scrollbar_state,
773 );
774 }
775}
776
777fn draw_details_split(f: &mut Frame, area: Rect, app: &App) {
778 let chunks = Layout::default()
779 .direction(Direction::Vertical)
780 .constraints([
781 Constraint::Percentage(50), Constraint::Percentage(50), ])
784 .split(area);
785
786 draw_request_details(f, chunks[0], app);
787 draw_response_details(f, chunks[1], app);
788}
789
790fn draw_response_details(f: &mut Frame, area: Rect, app: &App) {
791 let content = if let Some(exchange) = app.get_selected_exchange() {
792 let mut lines = Vec::new();
793
794 lines.push(Line::from(Span::styled(
796 "RESPONSE:",
797 Style::default()
798 .add_modifier(Modifier::BOLD)
799 .fg(Color::Blue),
800 )));
801 lines.push(build_tab_line(
802 &["Headers", "Body"],
803 app.response_tab,
804 matches!(app.focus, Focus::ResponseSection),
805 exchange.response.is_some(),
806 ));
807
808 if let Some(response) = &exchange.response {
809 if app.response_tab == 0 {
810 lines.push(Line::from(""));
812 match &response.headers {
813 Some(headers) if !headers.is_empty() => {
814 for (key, value) in headers {
815 lines.push(Line::from(format!(" {}: {}", key, value)));
816 }
817 }
818 Some(_) => {
819 lines.push(Line::from(" No headers"));
820 }
821 None => {
822 lines.push(Line::from(" No headers captured"));
823 }
824 }
825 } else {
826 lines.push(Line::from(""));
828 let mut response_json = serde_json::Map::new();
829 response_json.insert(
830 "jsonrpc".to_string(),
831 serde_json::Value::String("2.0".to_string()),
832 );
833
834 if let Some(id) = &response.id {
835 response_json.insert("id".to_string(), id.clone());
836 }
837 if let Some(result) = &response.result {
838 response_json.insert("result".to_string(), result.clone());
839 }
840 if let Some(error) = &response.error {
841 response_json.insert("error".to_string(), error.clone());
842 }
843
844 let response_json_value = serde_json::Value::Object(response_json);
845 let response_json_lines = format_json_with_highlighting(&response_json_value);
846 for line in response_json_lines {
847 lines.push(line);
848 }
849 }
850 } else {
851 lines.push(Line::from(""));
852 lines.push(Line::from(Span::styled(
853 "Response pending...",
854 Style::default().fg(Color::Yellow),
855 )));
856 }
857
858 lines
859 } else {
860 vec![Line::from("No request selected")]
861 };
862
863 let inner_area = area.inner(&Margin {
865 vertical: 1,
866 horizontal: 1,
867 });
868 let visible_lines = inner_area.height as usize;
869 let total_lines = content.len();
870
871 let start_line = app.response_details_scroll;
873 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
874 let visible_content = if start_line < total_lines {
875 content[start_line..end_line].to_vec()
876 } else {
877 vec![]
878 };
879
880 let base_title = "Response Details";
882
883 let scroll_info = if total_lines > visible_lines {
884 let progress = ((app.response_details_scroll as f32 / (total_lines - visible_lines) as f32)
885 * 100.0) as u8;
886 format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
887 } else {
888 base_title.to_string()
889 };
890
891 let details_block = if matches!(app.focus, Focus::ResponseSection) {
892 Block::default()
893 .borders(Borders::ALL)
894 .title(scroll_info)
895 .border_style(
896 Style::default()
897 .fg(Color::Yellow)
898 .add_modifier(Modifier::BOLD),
899 )
900 } else {
901 Block::default().borders(Borders::ALL).title(scroll_info)
902 };
903
904 let details = Paragraph::new(visible_content)
905 .block(details_block)
906 .wrap(Wrap { trim: false });
907
908 f.render_widget(details, area);
909
910 if total_lines > visible_lines {
911 let mut scrollbar_state =
912 ScrollbarState::new(total_lines).position(app.response_details_scroll);
913
914 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
915 .begin_symbol(None)
916 .end_symbol(None)
917 .track_symbol(None)
918 .thumb_symbol("▐");
919
920 f.render_stateful_widget(
921 scrollbar,
922 area.inner(&Margin {
923 vertical: 1,
924 horizontal: 0,
925 }),
926 &mut scrollbar_state,
927 );
928 }
929}
930
931#[derive(Clone)]
933struct KeybindInfo {
934 key: String,
935 description: String,
936 priority: u8, }
938
939impl KeybindInfo {
940 fn new(key: &str, description: &str, priority: u8) -> Self {
941 Self {
942 key: key.to_string(),
943 description: description.to_string(),
944 priority,
945 }
946 }
947
948 fn display_width(&self) -> usize {
950 self.key.len() + 1 + self.description.len() + 3 }
952
953 fn to_spans(&self) -> Vec<Span<'static>> {
955 vec![
956 Span::styled(
957 self.key.clone(),
958 Style::default()
959 .fg(Color::Yellow)
960 .add_modifier(Modifier::BOLD),
961 ),
962 Span::raw(format!(" {} | ", self.description)),
963 ]
964 }
965}
966
967fn get_keybinds_for_mode(app: &App) -> Vec<KeybindInfo> {
968 let mut keybinds = vec![
969 KeybindInfo::new("q", "quit", 1),
971 KeybindInfo::new("↑↓", "navigate", 1),
972 KeybindInfo::new("s", "start/stop proxy", 1),
973 KeybindInfo::new("Tab/Shift+Tab", "navigate", 2),
975 KeybindInfo::new("^n/^p", "navigate", 2),
976 KeybindInfo::new("t", "edit target", 2),
977 KeybindInfo::new("/", "filter", 2),
978 KeybindInfo::new("p", "pause", 2),
979 KeybindInfo::new("j/k/d/u/G/g", "scroll details", 3),
981 KeybindInfo::new("h/l", "navigate tabs", 3),
982 ];
983
984 match app.app_mode {
986 AppMode::Paused | AppMode::Intercepting => {
987 if !app.pending_requests.is_empty() {
989 keybinds.extend(vec![
990 KeybindInfo::new("a", "allow", 4),
991 KeybindInfo::new("e", "edit", 4),
992 KeybindInfo::new("h", "headers", 4),
993 KeybindInfo::new("c", "complete", 4),
994 KeybindInfo::new("b", "block", 4),
995 KeybindInfo::new("r", "resume", 4),
996 ]);
997 }
998 }
999 AppMode::Normal => {
1000 keybinds.push(KeybindInfo::new("c", "create request", 4));
1001 }
1002 }
1003
1004 keybinds
1005}
1006
1007fn arrange_keybinds_responsive(
1008 keybinds: Vec<KeybindInfo>,
1009 available_width: usize,
1010) -> Vec<Vec<Span<'static>>> {
1011 let mut lines = Vec::new();
1012 let mut current_line_spans = Vec::new();
1013 let mut current_line_width = 0;
1014
1015 let usable_width = available_width.saturating_sub(4);
1017
1018 let mut sorted_keybinds = keybinds;
1020 sorted_keybinds.sort_by_key(|k| k.priority);
1021
1022 for (i, keybind) in sorted_keybinds.iter().enumerate() {
1023 let keybind_width = keybind.display_width();
1024 let is_last = i == sorted_keybinds.len() - 1;
1025
1026 let width_needed = if is_last {
1028 keybind_width - 3 } else {
1030 keybind_width
1031 };
1032
1033 if current_line_width + width_needed <= usable_width || current_line_spans.is_empty() {
1034 let mut spans = keybind.to_spans();
1036 if is_last {
1037 if let Some(last_span) = spans.last_mut() {
1039 if let Some(content) = last_span.content.strip_suffix(" | ") {
1040 *last_span = Span::raw(content.to_string());
1041 }
1042 }
1043 }
1044 current_line_spans.extend(spans);
1045 current_line_width += width_needed;
1046 } else {
1047 if let Some(last_span) = current_line_spans.last_mut() {
1050 if let Some(content) = last_span.content.strip_suffix(" | ") {
1051 *last_span = Span::raw(content.to_string());
1052 }
1053 }
1054
1055 lines.push(current_line_spans);
1056 current_line_spans = keybind.to_spans();
1057 current_line_width = keybind_width;
1058
1059 if is_last {
1061 if let Some(last_span) = current_line_spans.last_mut() {
1062 if let Some(content) = last_span.content.strip_suffix(" | ") {
1063 *last_span = Span::raw(content.to_string());
1064 }
1065 }
1066 }
1067 }
1068 }
1069
1070 if !current_line_spans.is_empty() {
1072 lines.push(current_line_spans);
1073 }
1074
1075 lines
1076}
1077
1078fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
1079 let keybinds = get_keybinds_for_mode(app);
1080 let available_width = area.width as usize;
1081
1082 let line_spans = arrange_keybinds_responsive(keybinds, available_width);
1083
1084 let footer_text: Vec<Line> = line_spans.into_iter().map(Line::from).collect();
1086
1087 let footer =
1088 Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL).title("Controls"));
1089
1090 f.render_widget(footer, area);
1091}
1092
1093fn draw_input_dialog(f: &mut Frame, app: &App, title: &str, label: &str) {
1094 let area = f.size();
1095
1096 let popup_area = Rect {
1098 x: area.width / 4,
1099 y: area.height / 2 - 3,
1100 width: area.width / 2,
1101 height: 7,
1102 };
1103
1104 f.render_widget(Clear, area);
1106
1107 let background = Block::default().style(Style::default().bg(Color::Black));
1109 f.render_widget(background, area);
1110
1111 f.render_widget(Clear, popup_area);
1113
1114 let input_text = vec![
1115 Line::from(""),
1116 Line::from(vec![
1117 Span::raw(format!("{}: ", label)),
1118 Span::styled(&app.input_buffer, Style::default().fg(Color::Green)),
1119 ]),
1120 Line::from(""),
1121 Line::from(Span::styled(
1122 "Press Enter to confirm, Esc to cancel",
1123 Style::default().fg(Color::Gray),
1124 )),
1125 Line::from(""),
1126 ];
1127
1128 let input_dialog = Paragraph::new(input_text)
1129 .block(
1130 Block::default()
1131 .borders(Borders::ALL)
1132 .title(title)
1133 .style(Style::default().fg(Color::White).bg(Color::DarkGray)),
1134 )
1135 .wrap(Wrap { trim: true });
1136
1137 f.render_widget(input_dialog, popup_area);
1138}
1139
1140fn draw_intercept_content(f: &mut Frame, area: Rect, app: &App) {
1141 let chunks = Layout::default()
1142 .direction(Direction::Horizontal)
1143 .constraints([
1144 Constraint::Percentage(50), Constraint::Percentage(50), ])
1147 .split(area);
1148
1149 draw_pending_requests(f, chunks[0], app);
1150 draw_intercept_request_details(f, chunks[1], app);
1151}
1152
1153fn draw_pending_requests(f: &mut Frame, area: Rect, app: &App) {
1154 if app.pending_requests.is_empty() {
1155 let mode_text = match app.app_mode {
1156 AppMode::Paused => "Pause mode active. New requests will be intercepted.",
1157 _ => "No pending requests.",
1158 };
1159
1160 let paragraph = Paragraph::new(mode_text)
1161 .block(
1162 Block::default()
1163 .borders(Borders::ALL)
1164 .title("Pending Requests"),
1165 )
1166 .style(Style::default().fg(Color::Yellow))
1167 .wrap(Wrap { trim: true });
1168
1169 f.render_widget(paragraph, area);
1170 return;
1171 }
1172
1173 let requests: Vec<ListItem> = app
1174 .pending_requests
1175 .iter()
1176 .enumerate()
1177 .filter(|(_, pending)| {
1178 if app.filter_text.is_empty() {
1179 true
1180 } else {
1181 pending
1183 .original_request
1184 .method
1185 .as_deref()
1186 .unwrap_or("")
1187 .contains(&app.filter_text)
1188 }
1189 })
1190 .map(|(i, pending)| {
1191 let method = pending
1192 .original_request
1193 .method
1194 .as_deref()
1195 .unwrap_or("unknown");
1196 let id = pending
1197 .original_request
1198 .id
1199 .as_ref()
1200 .map(|v| v.to_string())
1201 .unwrap_or_else(|| "null".to_string());
1202
1203 let style = if i == app.selected_pending {
1204 Style::default()
1205 .bg(Color::Cyan)
1206 .fg(Color::Black)
1207 .add_modifier(Modifier::BOLD)
1208 } else {
1209 Style::default()
1210 };
1211
1212 let (icon, icon_color) =
1214 if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1215 ("✏ ", Color::Blue) } else {
1217 ("⏸ ", Color::Red) };
1219
1220 let mut modification_labels = Vec::new();
1221 if pending.modified_request.is_some() {
1222 modification_labels.push("BODY");
1223 }
1224 if pending.modified_headers.is_some() {
1225 modification_labels.push("HEADERS");
1226 }
1227 let modification_text = if !modification_labels.is_empty() {
1228 format!(" [{}]", modification_labels.join("+"))
1229 } else {
1230 String::new()
1231 };
1232
1233 ListItem::new(Line::from(vec![
1234 Span::styled(icon, Style::default().fg(icon_color)),
1235 Span::styled(format!("{} ", method), Style::default().fg(Color::Red)),
1236 Span::styled(format!("(id: {})", id), Style::default().fg(Color::Gray)),
1237 if !modification_text.is_empty() {
1238 Span::styled(
1239 modification_text,
1240 Style::default()
1241 .fg(Color::Blue)
1242 .add_modifier(Modifier::BOLD),
1243 )
1244 } else {
1245 Span::raw("")
1246 },
1247 ]))
1248 .style(style)
1249 })
1250 .collect();
1251
1252 let pending_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1253 Block::default()
1254 .borders(Borders::ALL)
1255 .title(format!("Pending Requests ({})", app.pending_requests.len()))
1256 .border_style(
1257 Style::default()
1258 .fg(Color::Yellow)
1259 .add_modifier(Modifier::BOLD),
1260 )
1261 } else {
1262 Block::default()
1263 .borders(Borders::ALL)
1264 .title(format!("Pending Requests ({})", app.pending_requests.len()))
1265 };
1266
1267 let requests_list = List::new(requests).block(pending_block).highlight_style(
1268 Style::default()
1269 .bg(Color::Cyan)
1270 .fg(Color::Black)
1271 .add_modifier(Modifier::BOLD),
1272 );
1273
1274 f.render_widget(requests_list, area);
1275}
1276
1277fn draw_intercept_request_details(f: &mut Frame, area: Rect, app: &App) {
1278 let content = if let Some(pending) = app.get_selected_pending() {
1279 let mut lines = Vec::new();
1280
1281 if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1282 lines.push(Line::from(Span::styled(
1283 "MODIFIED REQUEST:",
1284 Style::default()
1285 .add_modifier(Modifier::BOLD)
1286 .fg(Color::Blue),
1287 )));
1288 } else {
1289 lines.push(Line::from(Span::styled(
1290 "INTERCEPTED REQUEST:",
1291 Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
1292 )));
1293 }
1294 lines.push(Line::from(""));
1295
1296 lines.push(Line::from(Span::styled(
1298 "HTTP Headers:",
1299 Style::default()
1300 .add_modifier(Modifier::BOLD)
1301 .fg(Color::Green),
1302 )));
1303 let headers_to_show = pending
1304 .modified_headers
1305 .as_ref()
1306 .or(pending.original_request.headers.as_ref());
1307
1308 if let Some(headers) = headers_to_show {
1309 for (key, value) in headers {
1310 lines.push(Line::from(format!(" {}: {}", key, value)));
1311 }
1312 if pending.modified_headers.is_some() {
1313 lines.push(Line::from(Span::styled(
1314 " [Headers have been modified]",
1315 Style::default()
1316 .fg(Color::Blue)
1317 .add_modifier(Modifier::ITALIC),
1318 )));
1319 }
1320 } else {
1321 lines.push(Line::from(" No headers"));
1322 }
1323 lines.push(Line::from(""));
1324
1325 lines.push(Line::from(Span::styled(
1327 "JSON-RPC Request:",
1328 Style::default()
1329 .add_modifier(Modifier::BOLD)
1330 .fg(Color::Green),
1331 )));
1332
1333 let json_to_show = if let Some(ref modified_json) = pending.modified_request {
1335 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(modified_json) {
1336 parsed
1337 } else {
1338 let mut request_json = serde_json::Map::new();
1340 request_json.insert(
1341 "jsonrpc".to_string(),
1342 serde_json::Value::String("2.0".to_string()),
1343 );
1344
1345 if let Some(id) = &pending.original_request.id {
1346 request_json.insert("id".to_string(), id.clone());
1347 }
1348 if let Some(method) = &pending.original_request.method {
1349 request_json.insert(
1350 "method".to_string(),
1351 serde_json::Value::String(method.clone()),
1352 );
1353 }
1354 if let Some(params) = &pending.original_request.params {
1355 request_json.insert("params".to_string(), params.clone());
1356 }
1357
1358 serde_json::Value::Object(request_json)
1359 }
1360 } else {
1361 let mut request_json = serde_json::Map::new();
1363 request_json.insert(
1364 "jsonrpc".to_string(),
1365 serde_json::Value::String("2.0".to_string()),
1366 );
1367
1368 if let Some(id) = &pending.original_request.id {
1369 request_json.insert("id".to_string(), id.clone());
1370 }
1371 if let Some(method) = &pending.original_request.method {
1372 request_json.insert(
1373 "method".to_string(),
1374 serde_json::Value::String(method.clone()),
1375 );
1376 }
1377 if let Some(params) = &pending.original_request.params {
1378 request_json.insert("params".to_string(), params.clone());
1379 }
1380
1381 serde_json::Value::Object(request_json)
1382 };
1383
1384 let request_json_lines = format_json_with_highlighting(&json_to_show);
1385 for line in request_json_lines {
1386 lines.push(line);
1387 }
1388
1389 lines.push(Line::from(""));
1390 lines.push(Line::from(Span::styled(
1391 "Actions:",
1392 Style::default().add_modifier(Modifier::BOLD),
1393 )));
1394 lines.push(Line::from("• Press 'a' to Allow request"));
1395 lines.push(Line::from("• Press 'e' to Edit request body"));
1396 lines.push(Line::from("• Press 'h' to Edit headers"));
1397 lines.push(Line::from("• Press 'c' to Complete with custom response"));
1398 lines.push(Line::from("• Press 'b' to Block request"));
1399 lines.push(Line::from("• Press 'r' to Resume all requests"));
1400
1401 lines
1402 } else {
1403 vec![Line::from("No request selected")]
1404 };
1405
1406 let inner_area = area.inner(&Margin {
1408 vertical: 1,
1409 horizontal: 1,
1410 });
1411 let visible_lines = inner_area.height as usize;
1412 let total_lines = content.len();
1413
1414 let start_line = app.intercept_details_scroll;
1416 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
1417 let visible_content = if start_line < total_lines {
1418 content[start_line..end_line].to_vec()
1419 } else {
1420 vec![]
1421 };
1422
1423 let scroll_info = if total_lines > visible_lines {
1425 let progress = ((app.intercept_details_scroll as f32
1426 / (total_lines - visible_lines) as f32)
1427 * 100.0) as u8;
1428 format!("Request Details ({}% - vim: j/k/d/u/G/g)", progress)
1429 } else {
1430 "Request Details".to_string()
1431 };
1432
1433 let details_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1434 Block::default()
1435 .borders(Borders::ALL)
1436 .title(scroll_info)
1437 .border_style(
1438 Style::default()
1439 .fg(Color::Yellow)
1440 .add_modifier(Modifier::BOLD),
1441 )
1442 } else {
1443 Block::default().borders(Borders::ALL).title(scroll_info)
1444 };
1445
1446 let details = Paragraph::new(visible_content)
1447 .block(details_block)
1448 .wrap(Wrap { trim: false });
1449
1450 f.render_widget(details, area);
1451
1452 if total_lines > visible_lines {
1453 let mut scrollbar_state =
1454 ScrollbarState::new(total_lines).position(app.intercept_details_scroll);
1455
1456 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1457 .begin_symbol(None)
1458 .end_symbol(None)
1459 .track_symbol(None)
1460 .thumb_symbol("▐");
1461
1462 f.render_stateful_widget(
1463 scrollbar,
1464 area.inner(&Margin {
1465 vertical: 1,
1466 horizontal: 0,
1467 }),
1468 &mut scrollbar_state,
1469 );
1470 }
1471}