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, 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(3), 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::EditingTarget {
223 draw_input_dialog(f, app, "Edit Target URL", "Target URL");
224 } else if app.input_mode == InputMode::FilteringRequests {
225 draw_input_dialog(f, app, "Filter Requests", "Filter");
226 }
227}
228
229fn draw_header(f: &mut Frame, area: Rect, app: &App) {
230 let status = if app.is_running { "RUNNING" } else { "STOPPED" };
231 let status_color = if app.is_running {
232 Color::Green
233 } else {
234 Color::Red
235 };
236
237 let mode_text = match app.app_mode {
238 AppMode::Normal => String::new(),
239 AppMode::Paused => " | Mode: PAUSED".to_string(),
240 AppMode::Intercepting => format!(
241 " | Mode: INTERCEPTING ({} pending)",
242 app.pending_requests.len()
243 ),
244 };
245 let mode_color = match app.app_mode {
246 AppMode::Normal => Color::White,
247 AppMode::Paused => Color::Yellow,
248 AppMode::Intercepting => Color::Red,
249 };
250
251 let header_text = vec![Line::from(vec![
252 Span::raw("JSON-RPC Debugger | Status: "),
253 Span::styled(
254 status,
255 Style::default()
256 .fg(status_color)
257 .add_modifier(Modifier::BOLD),
258 ),
259 Span::raw(format!(
260 " | Port: {} | Target: {} | Filter: {}",
261 app.proxy_config.listen_port, app.proxy_config.target_url, app.filter_text
262 )),
263 Span::styled(
264 mode_text,
265 Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
266 ),
267 ])];
268
269 let header =
270 Paragraph::new(header_text).block(Block::default().borders(Borders::ALL).title("Status"));
271
272 f.render_widget(header, area);
273}
274
275fn draw_main_content(f: &mut Frame, area: Rect, app: &App) {
276 let chunks = Layout::default()
277 .direction(Direction::Horizontal)
278 .constraints([
279 Constraint::Percentage(50), Constraint::Percentage(50), ])
282 .split(area);
283
284 draw_message_list(f, chunks[0], app);
285 draw_details_split(f, chunks[1], app);
286}
287
288fn draw_message_list(f: &mut Frame, area: Rect, app: &App) {
289 if app.exchanges.is_empty() {
290 let empty_message = if app.is_running {
291 format!(
292 "Proxy is running on port {}. Waiting for JSON-RPC requests...",
293 app.proxy_config.listen_port
294 )
295 } else {
296 "Press 's' to start the proxy and begin capturing messages".to_string()
297 };
298
299 let paragraph = Paragraph::new(empty_message.as_str())
300 .block(Block::default().borders(Borders::ALL).title("JSON-RPC"))
301 .style(Style::default().fg(Color::Gray))
302 .wrap(Wrap { trim: true });
303
304 f.render_widget(paragraph, area);
305 return;
306 }
307
308 let header = Row::new(vec![
310 Cell::from("Status"),
311 Cell::from("Transport"),
312 Cell::from("Method"),
313 Cell::from("ID"),
314 Cell::from("Duration"),
315 ])
316 .style(Style::default().add_modifier(Modifier::BOLD))
317 .height(1);
318
319 let rows: Vec<Row> = app
321 .exchanges
322 .iter()
323 .enumerate()
324 .filter(|(_, exchange)| {
325 if app.filter_text.is_empty() {
326 true
327 } else {
328 exchange
330 .method
331 .as_deref()
332 .unwrap_or("")
333 .contains(&app.filter_text)
334 }
335 })
336 .map(|(i, exchange)| {
337 let transport_symbol = match exchange.transport {
338 TransportType::Http => "HTTP",
339 TransportType::WebSocket => "WS",
340 };
341
342 let method = exchange.method.as_deref().unwrap_or("unknown");
343 let id = exchange
344 .id
345 .as_ref()
346 .map(|v| match v {
347 serde_json::Value::String(s) => s.clone(),
348 serde_json::Value::Number(n) => n.to_string(),
349 _ => v.to_string(),
350 })
351 .unwrap_or_else(|| "null".to_string());
352
353 let (status_symbol, status_color) = if exchange.response.is_none() {
355 ("⏳ Pending", Color::Yellow)
356 } else if let Some(response) = &exchange.response {
357 if response.error.is_some() {
358 ("✗ Error", Color::Red)
359 } else {
360 ("✓ Success", Color::Green)
361 }
362 } else {
363 ("? Unknown", Color::Gray)
364 };
365
366 let duration_text =
368 if let (Some(request), Some(response)) = (&exchange.request, &exchange.response) {
369 match response.timestamp.duration_since(request.timestamp) {
370 Ok(duration) => {
371 let millis = duration.as_millis();
372 if millis < 1000 {
373 format!("{}ms", millis)
374 } else {
375 format!("{:.2}s", duration.as_secs_f64())
376 }
377 }
378 Err(_) => "-".to_string(),
379 }
380 } else {
381 "-".to_string()
382 };
383
384 let style = if i == app.selected_exchange {
385 Style::default()
386 .bg(Color::Cyan)
387 .fg(Color::Black)
388 .add_modifier(Modifier::BOLD)
389 } else {
390 Style::default()
391 };
392
393 Row::new(vec![
394 Cell::from(status_symbol).style(Style::default().fg(status_color)),
395 Cell::from(transport_symbol).style(Style::default().fg(Color::Blue)),
396 Cell::from(method).style(Style::default().fg(Color::Red)),
397 Cell::from(id).style(Style::default().fg(Color::Gray)),
398 Cell::from(duration_text).style(Style::default().fg(Color::Magenta)),
399 ])
400 .style(style)
401 .height(1)
402 })
403 .collect();
404
405 let table_title = "JSON-RPC";
406
407 let table_block = if matches!(app.focus, Focus::MessageList) {
408 Block::default()
409 .borders(Borders::ALL)
410 .title(table_title)
411 .border_style(
412 Style::default()
413 .fg(Color::Yellow)
414 .add_modifier(Modifier::BOLD),
415 )
416 } else {
417 Block::default().borders(Borders::ALL).title(table_title)
418 };
419
420 let table = Table::new(
421 rows,
422 [
423 Constraint::Length(12), Constraint::Length(9), Constraint::Min(15), Constraint::Length(12), Constraint::Length(10), ],
429 )
430 .header(header)
431 .block(table_block)
432 .highlight_style(
433 Style::default()
434 .bg(Color::Cyan)
435 .fg(Color::Black)
436 .add_modifier(Modifier::BOLD),
437 )
438 .highlight_symbol("→ ");
439
440 let mut table_state = TableState::default();
441 table_state.select(Some(app.selected_exchange));
442 f.render_stateful_widget(table, area, &mut table_state);
443
444 let filtered_count = app
445 .exchanges
446 .iter()
447 .filter(|exchange| {
448 if app.filter_text.is_empty() {
449 true
450 } else {
451 exchange
452 .method
453 .as_deref()
454 .unwrap_or("")
455 .contains(&app.filter_text)
456 }
457 })
458 .count();
459
460 if filtered_count > 0 {
461 let mut scrollbar_state =
462 ScrollbarState::new(filtered_count).position(app.selected_exchange);
463
464 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
465 .begin_symbol(None)
466 .end_symbol(None)
467 .track_symbol(None)
468 .thumb_symbol("▐");
469
470 f.render_stateful_widget(
471 scrollbar,
472 area.inner(&Margin {
473 vertical: 1,
474 horizontal: 0,
475 }),
476 &mut scrollbar_state,
477 );
478 }
479}
480
481fn draw_request_details(f: &mut Frame, area: Rect, app: &App) {
482 let content = if let Some(exchange) = app.get_selected_exchange() {
483 let mut lines = Vec::new();
484
485 lines.push(Line::from(vec![
487 Span::styled("Transport: ", Style::default().add_modifier(Modifier::BOLD)),
488 Span::raw(format!("{:?}", exchange.transport)),
489 ]));
490
491 if let Some(method) = &exchange.method {
492 lines.push(Line::from(vec![
493 Span::styled("Method: ", Style::default().add_modifier(Modifier::BOLD)),
494 Span::raw(method.clone()),
495 ]));
496 }
497
498 if let Some(id) = &exchange.id {
499 lines.push(Line::from(vec![
500 Span::styled("ID: ", Style::default().add_modifier(Modifier::BOLD)),
501 Span::raw(id.to_string()),
502 ]));
503 }
504
505 lines.push(Line::from(""));
507 lines.push(Line::from(Span::styled(
508 "REQUEST:",
509 Style::default()
510 .add_modifier(Modifier::BOLD)
511 .fg(Color::Green),
512 )));
513 lines.push(build_tab_line(
514 &["Headers", "Body"],
515 app.request_tab,
516 matches!(app.focus, Focus::RequestSection),
517 exchange.request.is_some(),
518 ));
519
520 if let Some(request) = &exchange.request {
521 if app.request_tab == 0 {
522 lines.push(Line::from(""));
524 match &request.headers {
525 Some(headers) if !headers.is_empty() => {
526 for (key, value) in headers {
527 lines.push(Line::from(format!(" {}: {}", key, value)));
528 }
529 }
530 Some(_) => {
531 lines.push(Line::from(" No headers"));
532 }
533 None => {
534 lines.push(Line::from(" No headers captured"));
535 }
536 }
537 } else {
538 lines.push(Line::from(""));
540 let mut request_json = serde_json::Map::new();
541 request_json.insert(
542 "jsonrpc".to_string(),
543 serde_json::Value::String("2.0".to_string()),
544 );
545
546 if let Some(id) = &request.id {
547 request_json.insert("id".to_string(), id.clone());
548 }
549 if let Some(method) = &request.method {
550 request_json.insert(
551 "method".to_string(),
552 serde_json::Value::String(method.clone()),
553 );
554 }
555 if let Some(params) = &request.params {
556 request_json.insert("params".to_string(), params.clone());
557 }
558
559 let request_json_value = serde_json::Value::Object(request_json);
560 let request_json_lines = format_json_with_highlighting(&request_json_value);
561 for line in request_json_lines {
562 lines.push(line);
563 }
564 }
565 } else {
566 lines.push(Line::from(""));
567 lines.push(Line::from("Request not captured yet"));
568 }
569
570 lines
571 } else {
572 vec![Line::from("No request selected")]
573 };
574
575 let inner_area = area.inner(&Margin {
577 vertical: 1,
578 horizontal: 1,
579 });
580 let visible_lines = inner_area.height as usize;
581 let total_lines = content.len();
582
583 let start_line = app.request_details_scroll;
585 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
586 let visible_content = if start_line < total_lines {
587 content[start_line..end_line].to_vec()
588 } else {
589 vec![]
590 };
591
592 let base_title = "Request Details";
594
595 let scroll_info = if total_lines > visible_lines {
596 let progress = ((app.request_details_scroll as f32 / (total_lines - visible_lines) as f32)
597 * 100.0) as u8;
598 format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
599 } else {
600 base_title.to_string()
601 };
602
603 let details_block = if matches!(app.focus, Focus::RequestSection) {
604 Block::default()
605 .borders(Borders::ALL)
606 .title(scroll_info)
607 .border_style(
608 Style::default()
609 .fg(Color::Yellow)
610 .add_modifier(Modifier::BOLD),
611 )
612 } else {
613 Block::default().borders(Borders::ALL).title(scroll_info)
614 };
615
616 let details = Paragraph::new(visible_content)
617 .block(details_block)
618 .wrap(Wrap { trim: false });
619
620 f.render_widget(details, area);
621
622 if total_lines > visible_lines {
623 let mut scrollbar_state =
624 ScrollbarState::new(total_lines).position(app.request_details_scroll);
625
626 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
627 .begin_symbol(None)
628 .end_symbol(None)
629 .track_symbol(None)
630 .thumb_symbol("▐");
631
632 f.render_stateful_widget(
633 scrollbar,
634 area.inner(&Margin {
635 vertical: 1,
636 horizontal: 0,
637 }),
638 &mut scrollbar_state,
639 );
640 }
641}
642
643fn draw_details_split(f: &mut Frame, area: Rect, app: &App) {
644 let chunks = Layout::default()
645 .direction(Direction::Vertical)
646 .constraints([
647 Constraint::Percentage(50), Constraint::Percentage(50), ])
650 .split(area);
651
652 draw_request_details(f, chunks[0], app);
653 draw_response_details(f, chunks[1], app);
654}
655
656fn draw_response_details(f: &mut Frame, area: Rect, app: &App) {
657 let content = if let Some(exchange) = app.get_selected_exchange() {
658 let mut lines = Vec::new();
659
660 lines.push(Line::from(Span::styled(
662 "RESPONSE:",
663 Style::default()
664 .add_modifier(Modifier::BOLD)
665 .fg(Color::Blue),
666 )));
667 lines.push(build_tab_line(
668 &["Headers", "Body"],
669 app.response_tab,
670 matches!(app.focus, Focus::ResponseSection),
671 exchange.response.is_some(),
672 ));
673
674 if let Some(response) = &exchange.response {
675 if app.response_tab == 0 {
676 lines.push(Line::from(""));
678 match &response.headers {
679 Some(headers) if !headers.is_empty() => {
680 for (key, value) in headers {
681 lines.push(Line::from(format!(" {}: {}", key, value)));
682 }
683 }
684 Some(_) => {
685 lines.push(Line::from(" No headers"));
686 }
687 None => {
688 lines.push(Line::from(" No headers captured"));
689 }
690 }
691 } else {
692 lines.push(Line::from(""));
694 let mut response_json = serde_json::Map::new();
695 response_json.insert(
696 "jsonrpc".to_string(),
697 serde_json::Value::String("2.0".to_string()),
698 );
699
700 if let Some(id) = &response.id {
701 response_json.insert("id".to_string(), id.clone());
702 }
703 if let Some(result) = &response.result {
704 response_json.insert("result".to_string(), result.clone());
705 }
706 if let Some(error) = &response.error {
707 response_json.insert("error".to_string(), error.clone());
708 }
709
710 let response_json_value = serde_json::Value::Object(response_json);
711 let response_json_lines = format_json_with_highlighting(&response_json_value);
712 for line in response_json_lines {
713 lines.push(line);
714 }
715 }
716 } else {
717 lines.push(Line::from(""));
718 lines.push(Line::from(Span::styled(
719 "Response pending...",
720 Style::default().fg(Color::Yellow),
721 )));
722 }
723
724 lines
725 } else {
726 vec![Line::from("No request selected")]
727 };
728
729 let inner_area = area.inner(&Margin {
731 vertical: 1,
732 horizontal: 1,
733 });
734 let visible_lines = inner_area.height as usize;
735 let total_lines = content.len();
736
737 let start_line = app.response_details_scroll;
739 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
740 let visible_content = if start_line < total_lines {
741 content[start_line..end_line].to_vec()
742 } else {
743 vec![]
744 };
745
746 let base_title = "Response Details";
748
749 let scroll_info = if total_lines > visible_lines {
750 let progress = ((app.response_details_scroll as f32 / (total_lines - visible_lines) as f32)
751 * 100.0) as u8;
752 format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
753 } else {
754 base_title.to_string()
755 };
756
757 let details_block = if matches!(app.focus, Focus::ResponseSection) {
758 Block::default()
759 .borders(Borders::ALL)
760 .title(scroll_info)
761 .border_style(
762 Style::default()
763 .fg(Color::Yellow)
764 .add_modifier(Modifier::BOLD),
765 )
766 } else {
767 Block::default().borders(Borders::ALL).title(scroll_info)
768 };
769
770 let details = Paragraph::new(visible_content)
771 .block(details_block)
772 .wrap(Wrap { trim: false });
773
774 f.render_widget(details, area);
775
776 if total_lines > visible_lines {
777 let mut scrollbar_state =
778 ScrollbarState::new(total_lines).position(app.response_details_scroll);
779
780 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
781 .begin_symbol(None)
782 .end_symbol(None)
783 .track_symbol(None)
784 .thumb_symbol("▐");
785
786 f.render_stateful_widget(
787 scrollbar,
788 area.inner(&Margin {
789 vertical: 1,
790 horizontal: 0,
791 }),
792 &mut scrollbar_state,
793 );
794 }
795}
796
797#[derive(Clone)]
799struct KeybindInfo {
800 key: String,
801 description: String,
802 priority: u8, }
804
805impl KeybindInfo {
806 fn new(key: &str, description: &str, priority: u8) -> Self {
807 Self {
808 key: key.to_string(),
809 description: description.to_string(),
810 priority,
811 }
812 }
813
814 fn display_width(&self) -> usize {
816 self.key.len() + 1 + self.description.len() + 3 }
818
819 fn to_spans(&self) -> Vec<Span<'static>> {
821 vec![
822 Span::styled(
823 self.key.clone(),
824 Style::default()
825 .fg(Color::Yellow)
826 .add_modifier(Modifier::BOLD),
827 ),
828 Span::raw(format!(" {} | ", self.description)),
829 ]
830 }
831}
832
833fn get_keybinds_for_mode(app: &App) -> Vec<KeybindInfo> {
834 let mut keybinds = vec![
835 KeybindInfo::new("q", "quit", 1),
837 KeybindInfo::new("↑↓", "navigate", 1),
838 KeybindInfo::new("s", "start/stop proxy", 1),
839 KeybindInfo::new("Tab/Shift+Tab", "navigate", 2),
841 KeybindInfo::new("^n/^p", "navigate", 2),
842 KeybindInfo::new("t", "edit target", 2),
843 KeybindInfo::new("/", "filter", 2),
844 KeybindInfo::new("p", "pause", 2),
845 KeybindInfo::new("j/k/d/u/G/g", "scroll details", 3),
847 KeybindInfo::new("h/l", "navigate tabs", 3),
848 ];
849
850 match app.app_mode {
852 AppMode::Paused | AppMode::Intercepting => {
853 if !app.pending_requests.is_empty() {
855 keybinds.extend(vec![
856 KeybindInfo::new("a", "allow", 4),
857 KeybindInfo::new("e", "edit", 4),
858 KeybindInfo::new("h", "headers", 4),
859 KeybindInfo::new("c", "complete", 4),
860 KeybindInfo::new("b", "block", 4),
861 KeybindInfo::new("r", "resume", 4),
862 ]);
863 }
864 }
865 AppMode::Normal => {
866 keybinds.push(KeybindInfo::new("c", "create request", 4));
867 }
868 }
869
870 keybinds
871}
872
873fn arrange_keybinds_responsive(
874 keybinds: Vec<KeybindInfo>,
875 available_width: usize,
876) -> Vec<Vec<Span<'static>>> {
877 let mut lines = Vec::new();
878 let mut current_line_spans = Vec::new();
879 let mut current_line_width = 0;
880
881 let usable_width = available_width.saturating_sub(4);
883
884 let mut sorted_keybinds = keybinds;
886 sorted_keybinds.sort_by_key(|k| k.priority);
887
888 for (i, keybind) in sorted_keybinds.iter().enumerate() {
889 let keybind_width = keybind.display_width();
890 let is_last = i == sorted_keybinds.len() - 1;
891
892 let width_needed = if is_last {
894 keybind_width - 3 } else {
896 keybind_width
897 };
898
899 if current_line_width + width_needed <= usable_width || current_line_spans.is_empty() {
900 let mut spans = keybind.to_spans();
902 if is_last {
903 if let Some(last_span) = spans.last_mut() {
905 if let Some(content) = last_span.content.strip_suffix(" | ") {
906 *last_span = Span::raw(content.to_string());
907 }
908 }
909 }
910 current_line_spans.extend(spans);
911 current_line_width += width_needed;
912 } else {
913 if let Some(last_span) = current_line_spans.last_mut() {
916 if let Some(content) = last_span.content.strip_suffix(" | ") {
917 *last_span = Span::raw(content.to_string());
918 }
919 }
920
921 lines.push(current_line_spans);
922 current_line_spans = keybind.to_spans();
923 current_line_width = keybind_width;
924
925 if is_last {
927 if let Some(last_span) = current_line_spans.last_mut() {
928 if let Some(content) = last_span.content.strip_suffix(" | ") {
929 *last_span = Span::raw(content.to_string());
930 }
931 }
932 }
933 }
934 }
935
936 if !current_line_spans.is_empty() {
938 lines.push(current_line_spans);
939 }
940
941 lines
942}
943
944fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
945 let keybinds = get_keybinds_for_mode(app);
946 let available_width = area.width as usize;
947
948 let line_spans = arrange_keybinds_responsive(keybinds, available_width);
949
950 let footer_text: Vec<Line> = line_spans.into_iter().map(Line::from).collect();
952
953 let footer =
954 Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL).title("Controls"));
955
956 f.render_widget(footer, area);
957}
958
959fn draw_input_dialog(f: &mut Frame, app: &App, title: &str, label: &str) {
960 let area = f.size();
961
962 let popup_area = Rect {
964 x: area.width / 4,
965 y: area.height / 2 - 3,
966 width: area.width / 2,
967 height: 7,
968 };
969
970 f.render_widget(Clear, area);
972
973 let background = Block::default().style(Style::default().bg(Color::Black));
975 f.render_widget(background, area);
976
977 f.render_widget(Clear, popup_area);
979
980 let input_text = vec![
981 Line::from(""),
982 Line::from(vec![
983 Span::raw(format!("{}: ", label)),
984 Span::styled(&app.input_buffer, Style::default().fg(Color::Green)),
985 ]),
986 Line::from(""),
987 Line::from(Span::styled(
988 "Press Enter to confirm, Esc to cancel",
989 Style::default().fg(Color::Gray),
990 )),
991 Line::from(""),
992 ];
993
994 let input_dialog = Paragraph::new(input_text)
995 .block(
996 Block::default()
997 .borders(Borders::ALL)
998 .title(title)
999 .style(Style::default().fg(Color::White).bg(Color::DarkGray)),
1000 )
1001 .wrap(Wrap { trim: true });
1002
1003 f.render_widget(input_dialog, popup_area);
1004}
1005
1006fn draw_intercept_content(f: &mut Frame, area: Rect, app: &App) {
1007 let chunks = Layout::default()
1008 .direction(Direction::Horizontal)
1009 .constraints([
1010 Constraint::Percentage(50), Constraint::Percentage(50), ])
1013 .split(area);
1014
1015 draw_pending_requests(f, chunks[0], app);
1016 draw_intercept_request_details(f, chunks[1], app);
1017}
1018
1019fn draw_pending_requests(f: &mut Frame, area: Rect, app: &App) {
1020 if app.pending_requests.is_empty() {
1021 let mode_text = match app.app_mode {
1022 AppMode::Paused => "Pause mode active. New requests will be intercepted.",
1023 _ => "No pending requests.",
1024 };
1025
1026 let paragraph = Paragraph::new(mode_text)
1027 .block(
1028 Block::default()
1029 .borders(Borders::ALL)
1030 .title("Pending Requests"),
1031 )
1032 .style(Style::default().fg(Color::Yellow))
1033 .wrap(Wrap { trim: true });
1034
1035 f.render_widget(paragraph, area);
1036 return;
1037 }
1038
1039 let requests: Vec<ListItem> = app
1040 .pending_requests
1041 .iter()
1042 .enumerate()
1043 .filter(|(_, pending)| {
1044 if app.filter_text.is_empty() {
1045 true
1046 } else {
1047 pending
1049 .original_request
1050 .method
1051 .as_deref()
1052 .unwrap_or("")
1053 .contains(&app.filter_text)
1054 }
1055 })
1056 .map(|(i, pending)| {
1057 let method = pending
1058 .original_request
1059 .method
1060 .as_deref()
1061 .unwrap_or("unknown");
1062 let id = pending
1063 .original_request
1064 .id
1065 .as_ref()
1066 .map(|v| v.to_string())
1067 .unwrap_or_else(|| "null".to_string());
1068
1069 let style = if i == app.selected_pending {
1070 Style::default()
1071 .bg(Color::Cyan)
1072 .fg(Color::Black)
1073 .add_modifier(Modifier::BOLD)
1074 } else {
1075 Style::default()
1076 };
1077
1078 let (icon, icon_color) =
1080 if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1081 ("✏ ", Color::Blue) } else {
1083 ("⏸ ", Color::Red) };
1085
1086 let mut modification_labels = Vec::new();
1087 if pending.modified_request.is_some() {
1088 modification_labels.push("BODY");
1089 }
1090 if pending.modified_headers.is_some() {
1091 modification_labels.push("HEADERS");
1092 }
1093 let modification_text = if !modification_labels.is_empty() {
1094 format!(" [{}]", modification_labels.join("+"))
1095 } else {
1096 String::new()
1097 };
1098
1099 ListItem::new(Line::from(vec![
1100 Span::styled(icon, Style::default().fg(icon_color)),
1101 Span::styled(format!("{} ", method), Style::default().fg(Color::Red)),
1102 Span::styled(format!("(id: {})", id), Style::default().fg(Color::Gray)),
1103 if !modification_text.is_empty() {
1104 Span::styled(
1105 modification_text,
1106 Style::default()
1107 .fg(Color::Blue)
1108 .add_modifier(Modifier::BOLD),
1109 )
1110 } else {
1111 Span::raw("")
1112 },
1113 ]))
1114 .style(style)
1115 })
1116 .collect();
1117
1118 let pending_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1119 Block::default()
1120 .borders(Borders::ALL)
1121 .title(format!("Pending Requests ({})", app.pending_requests.len()))
1122 .border_style(
1123 Style::default()
1124 .fg(Color::Yellow)
1125 .add_modifier(Modifier::BOLD),
1126 )
1127 } else {
1128 Block::default()
1129 .borders(Borders::ALL)
1130 .title(format!("Pending Requests ({})", app.pending_requests.len()))
1131 };
1132
1133 let requests_list = List::new(requests).block(pending_block).highlight_style(
1134 Style::default()
1135 .bg(Color::Cyan)
1136 .fg(Color::Black)
1137 .add_modifier(Modifier::BOLD),
1138 );
1139
1140 f.render_widget(requests_list, area);
1141}
1142
1143fn draw_intercept_request_details(f: &mut Frame, area: Rect, app: &App) {
1144 let content = if let Some(pending) = app.get_selected_pending() {
1145 let mut lines = Vec::new();
1146
1147 if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1148 lines.push(Line::from(Span::styled(
1149 "MODIFIED REQUEST:",
1150 Style::default()
1151 .add_modifier(Modifier::BOLD)
1152 .fg(Color::Blue),
1153 )));
1154 } else {
1155 lines.push(Line::from(Span::styled(
1156 "INTERCEPTED REQUEST:",
1157 Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
1158 )));
1159 }
1160 lines.push(Line::from(""));
1161
1162 lines.push(Line::from(Span::styled(
1164 "HTTP Headers:",
1165 Style::default()
1166 .add_modifier(Modifier::BOLD)
1167 .fg(Color::Green),
1168 )));
1169 let headers_to_show = pending
1170 .modified_headers
1171 .as_ref()
1172 .or(pending.original_request.headers.as_ref());
1173
1174 if let Some(headers) = headers_to_show {
1175 for (key, value) in headers {
1176 lines.push(Line::from(format!(" {}: {}", key, value)));
1177 }
1178 if pending.modified_headers.is_some() {
1179 lines.push(Line::from(Span::styled(
1180 " [Headers have been modified]",
1181 Style::default()
1182 .fg(Color::Blue)
1183 .add_modifier(Modifier::ITALIC),
1184 )));
1185 }
1186 } else {
1187 lines.push(Line::from(" No headers"));
1188 }
1189 lines.push(Line::from(""));
1190
1191 lines.push(Line::from(Span::styled(
1193 "JSON-RPC Request:",
1194 Style::default()
1195 .add_modifier(Modifier::BOLD)
1196 .fg(Color::Green),
1197 )));
1198
1199 let json_to_show = if let Some(ref modified_json) = pending.modified_request {
1201 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(modified_json) {
1202 parsed
1203 } else {
1204 let mut request_json = serde_json::Map::new();
1206 request_json.insert(
1207 "jsonrpc".to_string(),
1208 serde_json::Value::String("2.0".to_string()),
1209 );
1210
1211 if let Some(id) = &pending.original_request.id {
1212 request_json.insert("id".to_string(), id.clone());
1213 }
1214 if let Some(method) = &pending.original_request.method {
1215 request_json.insert(
1216 "method".to_string(),
1217 serde_json::Value::String(method.clone()),
1218 );
1219 }
1220 if let Some(params) = &pending.original_request.params {
1221 request_json.insert("params".to_string(), params.clone());
1222 }
1223
1224 serde_json::Value::Object(request_json)
1225 }
1226 } else {
1227 let mut request_json = serde_json::Map::new();
1229 request_json.insert(
1230 "jsonrpc".to_string(),
1231 serde_json::Value::String("2.0".to_string()),
1232 );
1233
1234 if let Some(id) = &pending.original_request.id {
1235 request_json.insert("id".to_string(), id.clone());
1236 }
1237 if let Some(method) = &pending.original_request.method {
1238 request_json.insert(
1239 "method".to_string(),
1240 serde_json::Value::String(method.clone()),
1241 );
1242 }
1243 if let Some(params) = &pending.original_request.params {
1244 request_json.insert("params".to_string(), params.clone());
1245 }
1246
1247 serde_json::Value::Object(request_json)
1248 };
1249
1250 let request_json_lines = format_json_with_highlighting(&json_to_show);
1251 for line in request_json_lines {
1252 lines.push(line);
1253 }
1254
1255 lines.push(Line::from(""));
1256 lines.push(Line::from(Span::styled(
1257 "Actions:",
1258 Style::default().add_modifier(Modifier::BOLD),
1259 )));
1260 lines.push(Line::from("• Press 'a' to Allow request"));
1261 lines.push(Line::from("• Press 'e' to Edit request body"));
1262 lines.push(Line::from("• Press 'h' to Edit headers"));
1263 lines.push(Line::from("• Press 'c' to Complete with custom response"));
1264 lines.push(Line::from("• Press 'b' to Block request"));
1265 lines.push(Line::from("• Press 'r' to Resume all requests"));
1266
1267 lines
1268 } else {
1269 vec![Line::from("No request selected")]
1270 };
1271
1272 let inner_area = area.inner(&Margin {
1274 vertical: 1,
1275 horizontal: 1,
1276 });
1277 let visible_lines = inner_area.height as usize;
1278 let total_lines = content.len();
1279
1280 let start_line = app.intercept_details_scroll;
1282 let end_line = std::cmp::min(start_line + visible_lines, total_lines);
1283 let visible_content = if start_line < total_lines {
1284 content[start_line..end_line].to_vec()
1285 } else {
1286 vec![]
1287 };
1288
1289 let scroll_info = if total_lines > visible_lines {
1291 let progress = ((app.intercept_details_scroll as f32
1292 / (total_lines - visible_lines) as f32)
1293 * 100.0) as u8;
1294 format!("Request Details ({}% - vim: j/k/d/u/G/g)", progress)
1295 } else {
1296 "Request Details".to_string()
1297 };
1298
1299 let details_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1300 Block::default()
1301 .borders(Borders::ALL)
1302 .title(scroll_info)
1303 .border_style(
1304 Style::default()
1305 .fg(Color::Yellow)
1306 .add_modifier(Modifier::BOLD),
1307 )
1308 } else {
1309 Block::default().borders(Borders::ALL).title(scroll_info)
1310 };
1311
1312 let details = Paragraph::new(visible_content)
1313 .block(details_block)
1314 .wrap(Wrap { trim: false });
1315
1316 f.render_widget(details, area);
1317
1318 if total_lines > visible_lines {
1319 let mut scrollbar_state =
1320 ScrollbarState::new(total_lines).position(app.intercept_details_scroll);
1321
1322 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1323 .begin_symbol(None)
1324 .end_symbol(None)
1325 .track_symbol(None)
1326 .thumb_symbol("▐");
1327
1328 f.render_stateful_widget(
1329 scrollbar,
1330 area.inner(&Margin {
1331 vertical: 1,
1332 horizontal: 0,
1333 }),
1334 &mut scrollbar_state,
1335 );
1336 }
1337}