netbook 0.2.7

A lightweight TUI request collection manager and runner - a Postman alternative for the terminal
Documentation
use ratatui::{
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
};

pub struct JsonTreeComponent {
    pub expanded_paths: std::collections::HashSet<String>,
}

impl JsonTreeComponent {
    pub fn new() -> Self {
        Self {
            expanded_paths: std::collections::HashSet::new(),
        }
    }

    pub fn render_json_tree(
        &self,
        json: &serde_json::Value,
        path: &str,
        depth: usize,
    ) -> Vec<Line> {
        let mut lines = Vec::new();
        let indent = "  ".repeat(depth);

        match json {
            serde_json::Value::Object(obj) => {
                let is_expanded = self.expanded_paths.contains(path);
                let icon = if is_expanded { "" } else { "" };

                lines.push(Line::from(vec![
                    Span::raw(indent.clone()),
                    Span::styled(icon, Style::default().fg(Color::Blue)),
                    Span::raw(" {"),
                ]));

                if is_expanded {
                    for (key, value) in obj {
                        let child_path = if path.is_empty() {
                            key.clone()
                        } else {
                            format!("{}.{}", path, key)
                        };

                        lines.push(Line::from(vec![
                            Span::raw(format!("{}  ", &indent)),
                            Span::styled(format!("\"{}\"", key), Style::default().fg(Color::Cyan)),
                            Span::raw(": "),
                        ]));

                        let mut child_lines = self.render_json_tree(value, &child_path, depth + 1);
                        lines.append(&mut child_lines);
                    }
                    lines.push(Line::from(format!("{}}}", &indent)));
                }
            }
            serde_json::Value::Array(arr) => {
                let is_expanded = self.expanded_paths.contains(path);
                let icon = if is_expanded { "" } else { "" };

                lines.push(Line::from(vec![
                    Span::raw(indent.clone()),
                    Span::styled(icon, Style::default().fg(Color::Blue)),
                    Span::raw(format!(" [{} items]", arr.len())),
                ]));

                if is_expanded {
                    for (index, value) in arr.iter().enumerate() {
                        let child_path = if path.is_empty() {
                            index.to_string()
                        } else {
                            format!("{}[{}]", path, index)
                        };

                        lines.push(Line::from(vec![
                            Span::raw(format!("{}  ", &indent)),
                            Span::styled(
                                format!("[{}]", index),
                                Style::default().fg(Color::Yellow),
                            ),
                            Span::raw(": "),
                        ]));

                        let mut child_lines = self.render_json_tree(value, &child_path, depth + 1);
                        lines.append(&mut child_lines);
                    }
                    lines.push(Line::from(format!("{}]", &indent)));
                }
            }
            serde_json::Value::String(s) => {
                lines.push(Line::from(vec![
                    Span::raw(indent.clone()),
                    Span::styled(format!("\"{}\"", s), Style::default().fg(Color::Green)),
                ]));
            }
            serde_json::Value::Number(n) => {
                lines.push(Line::from(vec![
                    Span::raw(indent.clone()),
                    Span::styled(n.to_string(), Style::default().fg(Color::Magenta)),
                ]));
            }
            serde_json::Value::Bool(b) => {
                lines.push(Line::from(vec![
                    Span::raw(indent.clone()),
                    Span::styled(b.to_string(), Style::default().fg(Color::Red)),
                ]));
            }
            serde_json::Value::Null => {
                lines.push(Line::from(vec![
                    Span::raw(indent),
                    Span::styled("null", Style::default().fg(Color::DarkGray)),
                ]));
            }
        }

        lines
    }

    pub fn toggle_path(&mut self, path: &str) {
        if self.expanded_paths.contains(path) {
            self.expanded_paths.remove(path);
        } else {
            self.expanded_paths.insert(path.to_string());
        }
    }
}

impl Default for JsonTreeComponent {
    fn default() -> Self {
        Self::new()
    }
}

pub fn create_loading_widget(message: &str) -> Paragraph {
    let spinner_chars = ['', '', '', '', '', '', '', '', '', ''];
    let spinner_index = (std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis()
        / 100)
        % spinner_chars.len() as u128;

    let content = vec![Line::from(vec![
        Span::styled(
            spinner_chars[spinner_index as usize].to_string(),
            Style::default().fg(Color::Yellow),
        ),
        Span::raw(" "),
        Span::raw(message),
    ])];

    Paragraph::new(content)
        .block(Block::default().borders(Borders::ALL))
        .style(Style::default().fg(Color::Yellow))
}

pub fn format_duration(millis: u64) -> String {
    if millis < 1000 {
        format!("{}ms", millis)
    } else if millis < 60000 {
        format!("{:.1}s", millis as f64 / 1000.0)
    } else {
        let minutes = millis / 60000;
        let seconds = (millis % 60000) as f64 / 1000.0;
        format!("{}m {:.1}s", minutes, seconds)
    }
}

pub fn format_file_size(bytes: usize) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
        size /= 1024.0;
        unit_index += 1;
    }

    if unit_index == 0 {
        format!("{} {}", bytes, UNITS[unit_index])
    } else {
        format!("{:.1} {}", size, UNITS[unit_index])
    }
}

pub fn truncate_text(text: &str, max_len: usize) -> String {
    if text.len() <= max_len {
        text.to_string()
    } else {
        format!("{}...", &text[..max_len.saturating_sub(3)])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_duration() {
        assert_eq!(format_duration(500), "500ms");
        assert_eq!(format_duration(1500), "1.5s");
        assert_eq!(format_duration(65000), "1m 5.0s");
    }

    #[test]
    fn test_format_file_size() {
        assert_eq!(format_file_size(500), "500 B");
        assert_eq!(format_file_size(1500), "1.5 KB");
        assert_eq!(format_file_size(1_500_000), "1.4 MB");
    }

    #[test]
    fn test_truncate_text() {
        assert_eq!(truncate_text("hello", 10), "hello");
        assert_eq!(truncate_text("hello world", 8), "hello...");
        assert_eq!(truncate_text("hi", 5), "hi");
    }

    #[test]
    fn test_json_tree_component() {
        let mut tree = JsonTreeComponent::new();
        let json = serde_json::json!({"name": "test", "value": 42});

        let lines_len = tree.render_json_tree(&json, "", 0).len();
        assert!(lines_len > 0);

        tree.toggle_path("");
        let expanded_lines_len = tree.render_json_tree(&json, "", 0).len();
        assert!(expanded_lines_len > lines_len);
    }
}