logana 0.6.0

Turn any log source — files, compressed archives, Docker, or OTel streams — into structured data. Filter by pattern, field, or date range; annotate lines; bookmark findings; and export to Markdown, Jira, or AI assistants via the built-in MCP server.
Documentation
use ratatui::{
    prelude::*,
    style::Modifier,
    widgets::{Block, Borders, Paragraph},
};

use crate::config::Keybindings;
use crate::mode::export_footer_mode::placeholder_to_label;
use crate::theme::Theme;

use super::popup_entry;

pub struct ExportFooterPopup<'a> {
    pub theme: &'a Theme,
    pub keybindings: &'a Keybindings,
    pub path: &'a str,
    pub fields: &'a [(String, Vec<String>)],
    pub active_idx: usize,
    pub cursor_row: usize,
    pub cursor_col: usize,
}

struct LayoutParams {
    popup_x: u16,
    popup_y: u16,
    popup_w: u16,
    popup_h: u16,
    field_h: u16,
    n_fields: u16,
}

impl LayoutParams {
    const LABEL_H: u16 = 1;
    const SEP_H: u16 = 1;
    const FOOTER_H: u16 = 1;

    fn fixed_rows(n: u16) -> u16 {
        n * Self::LABEL_H + n.saturating_sub(1) * Self::SEP_H + Self::SEP_H + Self::FOOTER_H
    }

    fn field_text_y(&self, idx: usize) -> u16 {
        1 + idx as u16 * (Self::LABEL_H + self.field_h + Self::SEP_H) + Self::LABEL_H
    }
}

impl<'a> ExportFooterPopup<'a> {
    fn layout(&self, area: Rect) -> LayoutParams {
        let n = self.fields.len() as u16;
        let popup_w = area.width.saturating_sub(8).clamp(50, 80);
        let min_h = 8 + n * 2;
        let popup_h = area.height.saturating_sub(4).min(24).max(min_h);
        let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
        let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
        let inner_h = popup_h.saturating_sub(2);
        let fixed = LayoutParams::fixed_rows(n);
        let field_h = inner_h.saturating_sub(fixed).checked_div(n).unwrap_or(0);
        LayoutParams {
            popup_x: x,
            popup_y: y,
            popup_w,
            popup_h,
            field_h,
            n_fields: n,
        }
    }

    fn scroll_for(cursor_row: usize, field_h: u16) -> u16 {
        cursor_row.saturating_sub(field_h.saturating_sub(1) as usize) as u16
    }

    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
        let p = self.layout(area);
        let text_y_offset = p.field_text_y(self.active_idx);
        let scroll = Self::scroll_for(self.cursor_row, p.field_h);
        let text_area_y = p.popup_y + text_y_offset;
        let text_area_x = p.popup_x + 1;
        let inner_w = p.popup_w.saturating_sub(2);
        let cur_x = text_area_x + self.cursor_col as u16;
        let cur_y = text_area_y + self.cursor_row as u16 - scroll;
        if cur_x < text_area_x + inner_w && cur_y < text_area_y + p.field_h {
            Some((cur_x, cur_y))
        } else {
            None
        }
    }
}

fn truncated_path(path: &str, popup_w: u16) -> String {
    if path.len() > popup_w as usize - 12 {
        format!("{}", &path[path.len() - (popup_w as usize - 13)..])
    } else {
        path.to_string()
    }
}

fn label_style(active: bool, theme: &Theme) -> Style {
    if active {
        Style::default()
            .fg(theme.text_highlight_fg)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(theme.text)
    }
}

fn render_field_label(text: &str, active: bool, theme: &Theme, area: Rect, buf: &mut Buffer) {
    Paragraph::new(text)
        .style(label_style(active, theme))
        .render(area, buf);
}

fn render_text_field(lines: &[String], scroll: u16, theme: &Theme, area: Rect, buf: &mut Buffer) {
    let content: Vec<Line> = lines.iter().map(|l| Line::from(l.as_str())).collect();
    Paragraph::new(content)
        .scroll((scroll, 0))
        .style(Style::default().fg(theme.text).bg(theme.root_bg))
        .render(area, buf);
}

fn render_section_separator(theme: &Theme, area: Rect, buf: &mut Buffer) {
    let sep = "\u{2500}".repeat(area.width as usize);
    Paragraph::new(sep)
        .style(Style::default().fg(theme.border_title).bg(theme.root_bg))
        .render(area, buf);
}

fn render_footer_separator(theme: &Theme, area: Rect, buf: &mut Buffer) {
    let sep = "\u{2500}".repeat(area.width as usize);
    Paragraph::new(sep)
        .style(Style::default().fg(theme.text).bg(theme.root_bg))
        .render(area, buf);
}

fn render_keybindings(theme: &Theme, area: Rect, buf: &mut Buffer) {
    let key_style = Style::default()
        .fg(theme.text_highlight_fg)
        .add_modifier(Modifier::BOLD);
    let txt_style = Style::default().fg(theme.text);
    let br_style = Style::default().fg(theme.text);
    let mut spans: Vec<Span<'static>> = Vec::new();
    for (key, label) in [
        ("Tab", "switch"),
        ("Enter", "newline"),
        ("Ctrl-S", "export"),
        ("Esc", "cancel"),
    ] {
        popup_entry(
            &mut spans,
            key.to_string(),
            label,
            key_style,
            txt_style,
            br_style,
        );
    }
    Paragraph::new(Line::from(spans))
        .style(Style::default().bg(theme.root_bg))
        .render(area, buf);
}

impl<'a> Widget for ExportFooterPopup<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let p = self.layout(area);
        let popup_area = Rect::new(p.popup_x, p.popup_y, p.popup_w, p.popup_h);
        ratatui::widgets::Clear.render(popup_area, buf);

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(self.theme.border_title))
            .title(format!(
                " Export: {} ",
                truncated_path(self.path, p.popup_w)
            ))
            .title_style(
                Style::default()
                    .fg(self.theme.text_highlight_fg)
                    .add_modifier(Modifier::BOLD),
            )
            .title_alignment(Alignment::Center)
            .style(Style::default().bg(self.theme.root_bg));

        let inner = block.inner(popup_area);
        block.render(popup_area, buf);

        let mut constraints: Vec<Constraint> = Vec::new();
        for i in 0..p.n_fields as usize {
            constraints.push(Constraint::Length(LayoutParams::LABEL_H));
            constraints.push(Constraint::Length(p.field_h));
            if i + 1 < p.n_fields as usize {
                constraints.push(Constraint::Length(LayoutParams::SEP_H));
            }
        }
        constraints.push(Constraint::Length(LayoutParams::SEP_H));
        constraints.push(Constraint::Length(LayoutParams::FOOTER_H));

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints(constraints)
            .split(inner);

        let mut chunk_idx = 0;
        for (i, (name, lines)) in self.fields.iter().enumerate() {
            let active = i == self.active_idx;
            let label = placeholder_to_label(name);
            render_field_label(&label, active, self.theme, chunks[chunk_idx], buf);
            chunk_idx += 1;
            let scroll = if active {
                Self::scroll_for(self.cursor_row, p.field_h)
            } else {
                0
            };
            render_text_field(lines, scroll, self.theme, chunks[chunk_idx], buf);
            chunk_idx += 1;
            if i + 1 < self.fields.len() {
                render_section_separator(self.theme, chunks[chunk_idx], buf);
                chunk_idx += 1;
            }
        }
        render_footer_separator(self.theme, chunks[chunk_idx], buf);
        chunk_idx += 1;
        render_keybindings(self.theme, chunks[chunk_idx], buf);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Keybindings;
    use crate::db::{Database, LogManager};
    use crate::ingestion::FileReader;
    use crate::mode::export_footer_mode::ExportFooterMode;
    use crate::theme::Theme;
    use crate::ui::App;
    use ratatui::{Terminal, backend::TestBackend};
    use std::sync::Arc;

    async fn make_app() -> App {
        let db = Arc::new(Database::in_memory().await.unwrap());
        let lm = LogManager::new(db, None).await;
        App::builder(
            lm,
            FileReader::from_bytes(b"line\n".to_vec()),
            Theme::default(),
            Arc::new(Keybindings::default()),
        )
        .build()
        .await
    }

    fn make_popup<'a>(
        theme: &'a Theme,
        kb: &'a Keybindings,
        fields: &'a [(String, Vec<String>)],
        active_idx: usize,
    ) -> ExportFooterPopup<'a> {
        ExportFooterPopup {
            theme,
            keybindings: kb,
            path: "/tmp/out.md",
            fields,
            active_idx,
            cursor_row: 0,
            cursor_col: 0,
        }
    }

    #[test]
    fn test_cursor_first_field_row0_col0() {
        let theme = Theme::default();
        let kb = Keybindings::default();
        let fields: Vec<(String, Vec<String>)> = vec![
            ("conclusion".to_string(), vec!["hello".to_string()]),
            ("next_steps".to_string(), vec![String::new()]),
        ];
        let popup = make_popup(&theme, &kb, &fields, 0);
        let area = Rect::new(0, 0, 80, 24);
        let pos = popup.cursor_position(area);
        assert!(pos.is_some(), "cursor must be visible");
        let (cx, cy) = pos.unwrap();
        let p = popup.layout(area);
        assert_eq!(cy, p.popup_y + p.field_text_y(0));
        assert_eq!(cx, p.popup_x + 1);
    }

    #[test]
    fn test_cursor_second_field_row0_col0() {
        let theme = Theme::default();
        let kb = Keybindings::default();
        let fields: Vec<(String, Vec<String>)> = vec![
            ("conclusion".to_string(), vec![String::new()]),
            ("next_steps".to_string(), vec!["step".to_string()]),
        ];
        let popup = make_popup(&theme, &kb, &fields, 1);
        let area = Rect::new(0, 0, 80, 24);
        let pos = popup.cursor_position(area);
        assert!(pos.is_some(), "cursor must be visible");
        let (_, cy) = pos.unwrap();
        let p = popup.layout(area);
        assert_eq!(cy, p.popup_y + p.field_text_y(1));
    }

    #[test]
    fn test_cursor_position_dynamic_fields() {
        let theme = Theme::default();
        let kb = Keybindings::default();
        let fields: Vec<(String, Vec<String>)> = vec![
            ("conclusion".to_string(), vec![String::new()]),
            ("next_steps".to_string(), vec![String::new()]),
        ];
        let popup = make_popup(&theme, &kb, &fields, 0);
        let area = Rect::new(0, 0, 80, 24);
        let p = popup.layout(area);
        let y0 = p.field_text_y(0);
        let y1 = p.field_text_y(1);
        let gap = y1 - y0;
        assert_eq!(
            gap,
            p.field_h + LayoutParams::SEP_H + LayoutParams::LABEL_H,
            "field_text_y formula: idx=1 should be idx=0 + field_h + sep + label"
        );
    }

    #[tokio::test]
    async fn test_export_footer_popup_renders_without_panic() {
        let mut app = make_app().await;
        app.tabs[0].interaction.mode = Box::new(ExportFooterMode::new(
            "/tmp/out.md".into(),
            "markdown".into(),
            vec!["conclusion".into(), "next_steps".into()],
        ));
        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
        terminal.draw(|f| app.ui(f)).unwrap();
    }

    #[tokio::test]
    async fn test_export_footer_popup_with_content() {
        let mut app = make_app().await;
        let mut mode = ExportFooterMode::new(
            "/tmp/out.md".into(),
            "markdown".into(),
            vec!["conclusion".into(), "next_steps".into()],
        );
        mode.fields[0].lines = vec!["all good".to_string()];
        mode.fields[1].lines = vec!["fix now".to_string()];
        app.tabs[0].interaction.mode = Box::new(mode);
        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
        terminal.draw(|f| app.ui(f)).unwrap();
    }

    #[tokio::test]
    async fn test_export_footer_popup_second_field_active() {
        let mut app = make_app().await;
        let mut mode = ExportFooterMode::new(
            "/tmp/out.md".into(),
            "markdown".into(),
            vec!["conclusion".into(), "next_steps".into()],
        );
        mode.active_idx = 1;
        mode.fields[1].lines = vec!["do this".to_string()];
        app.tabs[0].interaction.mode = Box::new(mode);
        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
        terminal.draw(|f| app.ui(f)).unwrap();
    }
}