sara-tasks 2.5.1

Sara — folder-aware task manager
use chrono::Local;
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Widget, Wrap},
};

use crate::infrastructure::tui::theme::{Ink, ink};

use super::lines::wrap_hanging;
use super::tree::task_tree_lines;
use crate::commands::info::handler::{comment_target, focusables};
use crate::commands::info::types::{Detail, EDIT_FIELDS, EditField, EditState, Focusable};

pub(super) fn side_panel(st: &EditState, area: Rect, buf: &mut Buffer) {
    let d = &st.detail;
    let tree_lines = task_tree_lines(d, st);
    let has_history = !d.history.is_empty();
    let tree_h: u16 = ((tree_lines.len() + 2) as u16).clamp(5, 24);
    let mut constraints = vec![if has_history || d.branch.is_some() {
        Constraint::Length(tree_h)
    } else {
        Constraint::Min(tree_h)
    }];
    if d.branch.is_some() {
        constraints.push(if has_history {
            Constraint::Length(3)
        } else {
            Constraint::Min(3)
        });
    }
    if has_history {
        constraints.push(Constraint::Min(3));
    }
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(area);

    let tree_title = if st.tree_expanded {
        " TASK TREE · EXPANDED "
    } else {
        " TASK TREE "
    };
    Paragraph::new(tree_lines)
        .block(panel_block(tree_title, ink(Ink::Special)))
        .render(chunks[0], buf);
    let mut next = 1;
    if d.branch.is_some() {
        Paragraph::new(git_panel_lines(d))
            .block(panel_block(" GIT ", ink(Ink::Muted)))
            .render(chunks[next], buf);
        next += 1;
    }
    if has_history {
        let width = chunks[next].width.saturating_sub(2) as usize;
        let (lines, _) = wrap_hanging(history_lines(&d.history, true), width, None);
        Paragraph::new(lines)
            .block(panel_block(" HISTORY ", ink(Ink::Muted)))
            .render(chunks[next], buf);
    }
}

fn panel_block(title: &str, color: ratatui::style::Color) -> Block<'_> {
    Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(color))
}

pub(super) fn history_pane(d: &Detail, area: Rect, buf: &mut Buffer) {
    Paragraph::new(history_lines(&d.history, false))
        .block(panel_block(" HISTORY ", ink(Ink::Muted)))
        .wrap(Wrap { trim: false })
        .render(area, buf);
}

pub(super) fn add_step_box(st: &EditState, area: Rect, buf: &mut Buffer) {
    let block = Block::default()
        .borders(Borders::ALL)
        .title(" Add step  (Enter save · Esc cancel) ".to_string())
        .border_style(Style::default().fg(ink(Ink::Ok)));
    editor_box(st, block, area, buf);
}

pub(super) fn comment_box(st: &EditState, area: Rect, buf: &mut Buffer) {
    let d = &st.detail;
    let items = focusables(d, st.show_notes, &st.open);
    let focus = items.get(st.selected).cloned();
    let (tk, tid) = comment_target(d, &focus);
    let target = match (tk, tid) {
        (Some(k), Some(i)) => format!("{k}:{i}"),
        _ => "task".to_string(),
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .title(format!(" Comment on {target}  (Enter save · Esc cancel) "))
        .border_style(Style::default().fg(ink(Ink::Warn)));
    editor_box(st, block, area, buf);
}

pub(super) fn edit_box(st: &EditState, area: Rect, buf: &mut Buffer) {
    let field = EDIT_FIELDS
        .get(st.selected)
        .copied()
        .unwrap_or(EditField::Description);
    let (title, border) = if st.due_error {
        (
            format!(" Editing {} — invalid date ", field.label()),
            ink(Ink::Err),
        )
    } else if let Some(ref err) = st.dep_error {
        (
            format!(" Editing {} — {} ", field.label(), err),
            ink(Ink::Err),
        )
    } else if field == EditField::DependsOn {
        (
            format!(
                " Editing {}  (task IDs, space/comma separated · Enter confirm · Esc cancel) ",
                field.label()
            ),
            ink(Ink::Warn),
        )
    } else {
        (
            format!(" Editing {}  (Enter confirm · Esc cancel) ", field.label()),
            ink(Ink::Warn),
        )
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(border));
    editor_box(st, block, area, buf);
}

fn editor_box(st: &EditState, block: Block, area: Rect, buf: &mut Buffer) {
    let inner = block.inner(area);
    block.render(area, buf);
    (&st.editor).render(inner, buf);
}

pub(super) fn footer(st: &EditState, area: Rect, buf: &mut Buffer) {
    let keys: Vec<(&str, &str)> = if st.adding_step {
        vec![("⏎", "save step"), ("Esc", "cancel")]
    } else if st.commenting {
        vec![("⏎", "save comment"), ("Esc", "cancel")]
    } else if st.editing {
        vec![("⏎", "confirm"), ("Esc", "cancel")]
    } else {
        let items = focusables(&st.detail, st.show_notes, &st.open);
        let mut keys = focus_keys(items.get(st.selected));
        keys.extend([
            ("j/k", "move"),
            ("a", "add step"),
            ("?", "help"),
            ("q", "close"),
        ]);
        keys
    };
    let mut spans = vec![Span::raw(" ")];
    for (i, (k, label)) in keys.iter().enumerate() {
        if i > 0 {
            spans.push(Span::styled("   ", Style::default()));
        }
        spans.push(Span::styled(
            k.to_string(),
            Style::default()
                .fg(ink(Ink::Accent))
                .add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::styled(
            format!(" {label}"),
            Style::default().fg(ink(Ink::Soft)),
        ));
    }
    Paragraph::new(Line::from(spans)).render(area, buf);
}

fn focus_keys(focus: Option<&Focusable>) -> Vec<(&'static str, &'static str)> {
    match focus {
        Some(Focusable::Field(EditField::Priority)) => vec![("←/→", "priority")],
        Some(Focusable::Field(_)) => vec![("e", "edit")],
        Some(Focusable::Section(_)) => vec![("⏎", "fold")],
        Some(Focusable::Checklist(_)) => {
            vec![("␣", "toggle"), ("K/J", "reorder"), ("c", "comment")]
        }
        Some(Focusable::Memory(_)) => vec![("⏎", "open in dream")],
        Some(Focusable::Link(_)) => vec![("⏎", "open"), ("c", "comment")],
        Some(Focusable::File(_)) => vec![("⏎", "open")],
        Some(Focusable::Anchor(_)) => vec![("⏎", "open"), ("r", "reconsider"), ("x", "resolve")],
        Some(Focusable::Note(_)) => vec![("c", "reply"), ("r", "reconsider"), ("x", "resolve")],
        Some(Focusable::Comment(_)) => vec![("⏎", "reply"), ("x", "resolve")],
        None => vec![],
    }
}

pub(in crate::commands::info) fn history_lines(
    history: &[crate::infrastructure::db::HistoryEntry],
    compact: bool,
) -> Vec<Line<'static>> {
    let mut lines = vec![];
    for h in history.iter().rev() {
        let date = h
            .changed_at
            .with_timezone(&Local)
            .format("%m-%d %H:%M")
            .to_string();
        let label = if h.field == "annotation" {
            "comment"
        } else {
            &h.field
        };
        let head = vec![
            Span::styled(format!(" {date}  "), Style::default().fg(ink(Ink::Muted))),
            Span::styled(
                if compact {
                    label.to_string()
                } else {
                    format!("{label:<11} ")
                },
                Style::default().fg(ink(Ink::Accent)),
            ),
        ];
        let mut spans = if compact {
            lines.push(Line::from(head));
            vec![Span::raw("   ")]
        } else {
            let mut head = head;
            head.insert(0, Span::raw(" "));
            head
        };
        let additive = matches!(
            h.field.as_str(),
            "annotation" | "link" | "dependency" | "checklist" | "file"
        ) && h.old_value.is_none() != h.new_value.is_none();
        if h.field == "created" {
            spans.push(Span::raw(h.new_value.clone().unwrap_or_default()));
        } else if additive {
            if let Some(text) = &h.new_value {
                spans.push(Span::styled("+ ", Style::default().fg(ink(Ink::Ok))));
                spans.push(Span::raw(text.clone()));
            } else if let Some(text) = &h.old_value {
                spans.push(Span::styled("− ", Style::default().fg(ink(Ink::Err))));
                spans.push(Span::raw(text.clone()));
            }
        } else {
            spans.push(Span::styled(
                h.old_value.clone().unwrap_or_else(|| "—".into()),
                Style::default().fg(ink(Ink::Soft)),
            ));
            spans.push(Span::styled(" → ", Style::default().fg(ink(Ink::Muted))));
            spans.push(Span::raw(h.new_value.clone().unwrap_or_else(|| "—".into())));
        }
        lines.push(Line::from(spans));
    }
    lines
}

fn git_panel_lines(d: &Detail) -> Vec<Line<'static>> {
    let Some(rec) = &d.branch else {
        return vec![];
    };
    vec![Line::from(vec![
        Span::styled(" ⎇ ", Style::default().fg(ink(Ink::Muted))),
        Span::styled(
            rec.branch.clone(),
            Style::default()
                .fg(ink(Ink::Accent))
                .add_modifier(Modifier::BOLD),
        ),
    ])]
}