jsonrpc_debugger/
ui.rs

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
14// Helper function to format JSON with syntax highlighting and 2-space indentation
15fn format_json_with_highlighting(json_value: &serde_json::Value) -> Vec<Line<'static>> {
16    // Use the standard pretty formatter
17    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        // Limit total lines to prevent UI issues
26        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        // Don't trim the line - work with it as-is to preserve indentation
35        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                    // Flush any accumulated token (including spaces)
43                    if !current_token.is_empty() {
44                        spans.push(Span::raw(current_token.clone()));
45                        current_token.clear();
46                    }
47
48                    // Collect the entire string
49                    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                    // Check if this is a key (followed by colon)
58                    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                        // This is a key
71                        spans.push(Span::styled(
72                            string_content,
73                            Style::default()
74                                .fg(Color::Cyan)
75                                .add_modifier(Modifier::BOLD),
76                        ));
77                    } else {
78                        // This is a string value
79                        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                    // Accumulate all other characters including spaces
113                    current_token.push(ch);
114                }
115            }
116        }
117
118        // Handle any remaining token (including trailing spaces)
119        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                // This includes spaces and other whitespace - preserve as-is
135                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            // Active tab - use a more prominent style like modern tab designs
158            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            // Inactive tab - subtle background
171            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            // Disabled tab
177            let style = Style::default().fg(Color::DarkGray);
178            spans.push(Span::styled(format!(" {} ", *label), style));
179        }
180
181        // Add separator between tabs
182        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    // Calculate footer height dynamically
192    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); // +2 for borders, minimum 3
196
197    let chunks = Layout::default()
198        .direction(Direction::Vertical)
199        .constraints([
200            Constraint::Length(3),                    // Header
201            Constraint::Min(10),                      // Main content
202            Constraint::Length(footer_height as u16), // Dynamic footer height
203            Constraint::Length(1),                    // Input dialog
204        ])
205        .split(f.size());
206
207    draw_header(f, chunks[0], app);
208
209    // Choose layout based on app mode
210    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    // Draw input dialogs
222    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), // Message list
280            Constraint::Percentage(50), // Details area
281        ])
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    // Create table headers
309    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    // Create table rows
320    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                // TODO: Filter by id, params, result, error, etc.
329                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            // Determine status
354            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            // Calculate duration if we have both request and response
367            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), // Status
424            Constraint::Length(9),  // Transport
425            Constraint::Min(15),    // Method (flexible)
426            Constraint::Length(12), // ID
427            Constraint::Length(10), // Duration
428        ],
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        // Basic exchange info
486        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        // Request section with tabs
506        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                // Show headers regardless of focus state
523                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                // Show body regardless of focus state
539                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    // Calculate visible area for scrolling
576    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    // Apply scrolling offset
584    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    // Create title with scroll indicator
593    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), // Request details
648            Constraint::Percentage(50), // Response details
649        ])
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        // Response section with tabs
661        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                // Show headers regardless of focus state
677                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                // Show body regardless of focus state
693                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    // Calculate visible area for scrolling
730    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    // Apply scrolling offset
738    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    // Create title with scroll indicator
747    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// Helper struct to represent a keybind with its display information
798#[derive(Clone)]
799struct KeybindInfo {
800    key: String,
801    description: String,
802    priority: u8, // Lower number = higher priority
803}
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    // Calculate the display width of this keybind (key + description + separators)
815    fn display_width(&self) -> usize {
816        self.key.len() + 1 + self.description.len() + 3 // " | " separator
817    }
818
819    // Convert to spans for rendering
820    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        // Essential keybinds (priority 1)
836        KeybindInfo::new("q", "quit", 1),
837        KeybindInfo::new("↑↓", "navigate", 1),
838        KeybindInfo::new("s", "start/stop proxy", 1),
839        // Navigation keybinds (priority 2)
840        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        // Advanced keybinds (priority 3)
846        KeybindInfo::new("j/k/d/u/G/g", "scroll details", 3),
847        KeybindInfo::new("h/l", "navigate tabs", 3),
848    ];
849
850    // Add context-specific keybinds (priority 4)
851    match app.app_mode {
852        AppMode::Paused | AppMode::Intercepting => {
853            // Only show intercept controls if there are pending requests
854            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    // Account for border padding (2 chars for left/right borders)
882    let usable_width = available_width.saturating_sub(4);
883
884    // Sort keybinds by priority
885    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        // Check if this keybind fits on the current line
893        let width_needed = if is_last {
894            keybind_width - 3 // Remove " | " from last item
895        } else {
896            keybind_width
897        };
898
899        if current_line_width + width_needed <= usable_width || current_line_spans.is_empty() {
900            // Add to current line
901            let mut spans = keybind.to_spans();
902            if is_last {
903                // Remove the trailing " | " from the last keybind
904                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            // Start a new line
914            // Remove trailing " | " from the last span of the current line
915            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 this is the last keybind, remove trailing separator
926            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    // Add the last line if it has content
937    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    // Convert spans to Lines
951    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    // Create a centered popup
963    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    // Clear the entire screen first
971    f.render_widget(Clear, area);
972
973    // Render a black background
974    let background = Block::default().style(Style::default().bg(Color::Black));
975    f.render_widget(background, area);
976
977    // Clear the popup area specifically
978    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), // Pending requests list
1011            Constraint::Percentage(50), // Request details/editor
1012        ])
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                // Filter pending requests by method name (same as main list)
1048                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            // Show different icon if request has been modified
1079            let (icon, icon_color) =
1080                if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1081                    ("✏ ", Color::Blue) // Modified
1082                } else {
1083                    ("⏸ ", Color::Red) // Paused/Intercepted
1084                };
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        // Show headers section
1163        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        // Show JSON-RPC body section
1192        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        // Show the modified request if available, otherwise show original
1200        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                // Fallback to original if modified JSON is invalid
1205                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            // Show original request
1228            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    // Calculate visible area for scrolling
1273    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    // Apply scrolling offset
1281    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    // Create title with scroll indicator
1290    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}