jjj 0.2.0

A modal interface for Jujutsu.
use bevy::prelude::*;
use bevy_ratatui::event::KeyEvent;
use crossterm::event::{KeyCode, KeyEventKind};
use ratatui::{
    prelude::{Rect, *},
    widgets::Block,
};

use crate::backend::{
    log::LogResponseEvent,
    revisions::{ChangeId, CommitId, Revision},
};

use super::prelude::*;

#[tracing::instrument(skip_all)]
pub fn plugin(app: &mut App) {
    trace!("Initializing plugin...");

    app.add_systems(
        Update,
        (
            (read_keys.pipe(errors::forward))
                .in_set(AppSet::RecordInput)
                .run_if(is_focused::<ChangeBuffer>),
            (read_revisions.pipe(errors::forward))
                .in_set(AppSet::Update)
                .run_if(in_state(Screen::Interface)),
        ),
    );

    trace!("Plugin initialized.");
}

#[derive(Event)]
pub struct ChangeBufferSelectionEvent(pub RevisionSelection);

#[derive(Clone, Component, Default)]
pub struct ChangeBuffer {
    revisions: Vec<Revision>,
    selection: IndexSelection,
    pub viewport_y: usize,
}

#[derive(Clone)]
pub enum IndexSelection {
    Single(usize),
    Range(usize, usize),
}

impl Default for IndexSelection {
    fn default() -> Self {
        IndexSelection::Single(0)
    }
}

#[derive(Clone)]
pub enum RevisionSelection {
    Single(Revision),
    Range(Revision, Revision),
}

fn read_keys(
    mut ev_selection: EventWriter<ChangeBufferSelectionEvent>,
    mut change_buffer: Query<&mut ChangeBuffer>,
    mut ev_keypresses: EventReader<KeyEvent>,
    mut exit: EventWriter<AppExit>,
    mut navigation: Navigation,
) -> Result<()> {
    let mut change_buffer = change_buffer.get_single_mut()?;

    if change_buffer.revisions.len() == 0 {
        return Ok(());
    }

    for keypress in ev_keypresses.read() {
        if keypress.kind != KeyEventKind::Press {
            continue;
        }

        let max = change_buffer.revisions.len().saturating_sub(1);

        let selection = match (change_buffer.selection.clone(), keypress.code) {
            (_, KeyCode::Char(' ')) => {
                navigation.spawn_popup(SpaceMenu)?;
                return Ok(());
            }
            (_, KeyCode::Char('q')) => {
                exit.send_default();
                return Ok(());
            }
            (IndexSelection::Single(i), KeyCode::Char('j')) => {
                Some(IndexSelection::Single(usize::min(i + 1, max)))
            }
            (IndexSelection::Single(i), KeyCode::Char('k')) => {
                Some(IndexSelection::Single(i.saturating_sub(1)))
            }
            (IndexSelection::Single(i), KeyCode::Char('x')) => {
                if i != (i + 1).clamp(0, max) {
                    Some(IndexSelection::Range(i, usize::min(i + 1, max)))
                } else {
                    Some(IndexSelection::Single(i))
                }
            }
            (IndexSelection::Range(_, end), KeyCode::Char('j')) => {
                Some(IndexSelection::Single(usize::min(end + 1, max)))
            }
            (IndexSelection::Range(start, _), KeyCode::Char('k')) => {
                Some(IndexSelection::Single(start.saturating_sub(1)))
            }
            (IndexSelection::Range(start, end), KeyCode::Char('x')) => {
                Some(IndexSelection::Range(start, usize::min(end + 1, max)))
            }
            _ => None,
        };

        if let Some(selection) = selection {
            change_buffer.selection = selection.clone();
            ev_selection.send(match selection {
                IndexSelection::Single(i) => ChangeBufferSelectionEvent(RevisionSelection::Single(
                    change_buffer.revisions[i].clone(),
                )),
                IndexSelection::Range(start, end) => {
                    ChangeBufferSelectionEvent(RevisionSelection::Range(
                        change_buffer.revisions[start].clone(),
                        change_buffer.revisions[end].clone(),
                    ))
                }
            });
        }
    }

    Ok(())
}

fn read_revisions(
    mut ev_selection: EventWriter<ChangeBufferSelectionEvent>,
    mut ev_log_response: EventReader<LogResponseEvent>,
    mut change_buffer: Query<&mut ChangeBuffer>,
) -> Result<()> {
    let mut change_buffer = change_buffer.get_single_mut()?;

    for LogResponseEvent(revisions) in ev_log_response.read() {
        if change_buffer.revisions.is_empty() {
            change_buffer.selection = IndexSelection::Single(0);
            change_buffer.revisions = revisions.clone();
            continue;
        }

        match change_buffer.selection {
            // Select the same change by change_id if it exists
            IndexSelection::Single(i) => {
                let change_id = &change_buffer.revisions[i].change_id;
                let index = (revisions.iter())
                    .position(|r| r.change_id.0 == *change_id.0)
                    .unwrap_or(0);

                change_buffer.selection = IndexSelection::Single(index);
                change_buffer.revisions = revisions.clone();
            }

            // Select the range of commits with the same start and end change_id if they exist
            IndexSelection::Range(start_prev, end_prev) => {
                let start_change_id = &change_buffer.revisions[start_prev].change_id;
                let end_change_id = &change_buffer.revisions[end_prev].change_id;

                change_buffer.selection = (revisions.iter())
                    .position(|r| r.change_id.0 == *start_change_id.0)
                    .zip((revisions.iter()).position(|r| r.change_id.0 == *end_change_id.0))
                    .map(|(start, end)| IndexSelection::Range(start, end))
                    .unwrap_or(IndexSelection::Single(0));
                change_buffer.revisions = revisions.clone();
            }
        }

        ev_selection.send(match change_buffer.selection {
            IndexSelection::Single(i) => ChangeBufferSelectionEvent(RevisionSelection::Single(
                change_buffer.revisions[i].clone(),
            )),
            IndexSelection::Range(start, end) => {
                ChangeBufferSelectionEvent(RevisionSelection::Range(
                    change_buffer.revisions[start].clone(),
                    change_buffer.revisions[end].clone(),
                ))
            }
        });
    }

    Ok(())
}

impl StatefulWidget for ChangeBuffer {
    type State = usize;

    fn render(self, area: Rect, buf: &mut Buffer, viewport_y: &mut Self::State) {
        let selected_rev = match self.selection {
            IndexSelection::Single(index) => index,
            IndexSelection::Range(_, end) => end,
        };

        let lines = (self.revisions.iter().enumerate())
            .flat_map(|(i, rev)| RevisionLine::vec_from(rev, i == selected_rev))
            .collect::<Vec<_>>();

        let (computed_viewport_y, line_range) = viewport::compute_sliding_window(
            lines.len(),
            2 * selected_rev,
            *viewport_y,
            area.height as usize,
            area.height as usize / 4,
        );

        *viewport_y = computed_viewport_y;
        let lines = &lines[line_range.clone()];

        let [revs_area, empty] = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(lines.len() as u16), Constraint::Fill(1)])
            .areas(area);

        let rev_lines = Layout::default()
            .direction(Direction::Vertical)
            .constraints(lines.iter().map(|_| Constraint::Length(1)))
            .split(revs_area);

        for (line, area) in lines.iter().zip(rev_lines.iter()) {
            line.clone().render(*area, buf);
        }

        EmptyBuffer.render(empty, buf);
    }
}

#[derive(Clone)]
enum RevisionLine {
    Top(RevisionTopLine),
    Bottom(RevisionBottomLine),
}

impl RevisionLine {
    fn vec_from(revision: &Revision, is_selected: bool) -> Vec<Self> {
        let graph = revision.graph.clone();
        vec![
            RevisionLine::Top(RevisionTopLine {
                graph: graph.head,
                change_id: revision.change_id.clone(),
                commit_id: revision.commit_id.clone(),
                is_root: revision.is_root,
                is_selected,
                author: revision.author.clone(),
                timestamp: revision.timestamp.clone(),
                bookmarks: revision.bookmarks.clone(),
            }),
            RevisionLine::Bottom(RevisionBottomLine {
                graph: graph.tail,
                is_empty: revision.is_empty,
                description: revision.description.clone(),
                is_selected,
            }),
        ]
    }
}

impl Widget for RevisionLine {
    fn render(self, area: Rect, buf: &mut Buffer)
    where
        Self: Sized,
    {
        match self {
            RevisionLine::Top(top) => top.render(area, buf),
            RevisionLine::Bottom(bottom) => bottom.render(area, buf),
        }
    }
}

#[derive(Clone)]
struct RevisionTopLine {
    graph: String,
    change_id: ChangeId,
    commit_id: CommitId,
    is_root: bool,
    is_selected: bool,
    author: String,
    timestamp: String,
    bookmarks: Vec<String>,
}

impl Widget for RevisionTopLine {
    fn render(self, area: Rect, buf: &mut Buffer)
    where
        Self: Sized,
    {
        let mut left = Line::from("  ");

        let change_id = self.change_id.into_parts();

        left += Span::from(self.graph.clone());
        left += Span::styled(change_id.head, Style::new().not_dim().light_magenta());
        left += Span::styled(change_id.tail, Style::new().dim());

        if self.is_root {
            left += Span::styled(" root()", Style::new().green());
        } else {
            left += Span::styled(format!(" {}", self.author), Style::new().yellow());
            left += Span::styled(format!(" {}", self.timestamp), Style::new().cyan());
        }

        // In case the window isn't wide enough for both left and right sides, make sure the left
        // side is legible and separate.
        left += "  ".into();

        let mut right = Line::default().alignment(Alignment::Right);

        for bookmark in self.bookmarks.iter() {
            right += Span::styled(format!("{bookmark} "), Style::new().magenta());
        }

        let commit_id = self.commit_id.into_parts();

        right += Span::styled(commit_id.head, Style::new().not_dim().blue());
        right += Span::styled(commit_id.tail, Style::new().dim());

        right += Span::from(" ");

        let [left_area, right_area] = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(left.width() as u16), Constraint::Fill(1)])
            .areas(area);

        if self.is_selected {
            Block::default()
                .style(Style::new().on_dark_gray())
                .render(area, buf)
        }

        right.render(right_area, buf);
        left.render(left_area, buf);
    }
}

#[derive(Clone)]
struct RevisionBottomLine {
    graph: String,
    is_empty: bool,
    description: Option<String>,
    is_selected: bool,
}

impl Widget for RevisionBottomLine {
    fn render(self, area: Rect, buf: &mut Buffer)
    where
        Self: Sized,
    {
        let mut bottom = Line::from("  ");

        bottom += Span::from(self.graph.clone());

        if self.is_empty {
            bottom += Span::styled("(empty) ", Style::new().green());
        }

        if let Some(description) =
            (self.description.clone()).and_then(|d| d.lines().nth(0).map(|d| d.to_string()))
        {
            bottom += Span::from(format!("{description} "));
        } else {
            bottom += Span::styled(
                "(no description set) ",
                if self.is_empty {
                    Style::new().green()
                } else {
                    Style::new().yellow()
                },
            );
        }

        if self.is_selected {
            Block::default()
                .style(Style::new().on_dark_gray())
                .render(area, buf)
        }

        bottom.render(area, buf);
    }
}