laziergit 1.0.0

an even lazier git implementation than lazygit
use crate::diff::{self, DiffRow};
use crate::field::{EditOp, Input};
use crate::git;
use crate::status::{self, FileEntry, RepoStatus};
use crate::theme::Theme;
use crate::tree::{self, Row, RowKind, TreeNode};
use color_eyre::Result;
use std::collections::HashSet;

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus {
    Sidebar,
    Diff,
}

pub enum Mode {
    Normal,
    Commit(Input),
    Branch(Input),
    Confirm,
    Help,
}

pub enum Action {
    Noop,
    Quit,
    NavNext,
    NavPrev,
    Collapse,
    Expand,
    EnterRow,
    ToggleFocus,
    ScrollDiff(i32),
    StageToggle,
    OpenCommit,
    OpenBranch,
    OpenConfirm,
    OpenHelp,
    Edit(EditOp),
    Submit,
    Cancel,
    Pull,
    Push,
}

pub struct App {
    pub status: RepoStatus,
    pub tree: Vec<TreeNode>,
    pub rows: Vec<Row>,
    pub collapsed: HashSet<usize>,
    pub selected: usize,
    pub focus: Focus,
    pub diff_rows: Vec<DiffRow>,
    pub diff_scroll: u16,
    pub mode: Mode,
    pub message: String,
    pub theme: Theme,
    quit: bool,
}

impl App {
    pub fn new() -> Self {
        let mut app = App {
            status: RepoStatus::default(),
            tree: Vec::new(),
            rows: Vec::new(),
            collapsed: HashSet::new(),
            selected: 0,
            focus: Focus::Sidebar,
            diff_rows: Vec::new(),
            diff_scroll: 0,
            mode: Mode::Normal,
            message: String::new(),
            theme: Theme::detect(),
            quit: false,
        };
        app.refresh();
        app
    }

    pub fn should_quit(&self) -> bool {
        self.quit
    }

    pub fn perform(&mut self, action: Action) {
        match action {
            Action::Noop => {}
            Action::Quit => self.quit = true,
            Action::NavNext => self.move_selection(1),
            Action::NavPrev => self.move_selection(-1),
            Action::Collapse => self.collapse(),
            Action::Expand => self.expand(),
            Action::EnterRow => self.enter_row(),
            Action::ToggleFocus => self.toggle_focus(),
            Action::ScrollDiff(delta) => {
                self.diff_scroll = (self.diff_scroll as i32 + delta).max(0) as u16
            }
            Action::StageToggle => self.stage_toggle(),
            Action::OpenCommit => self.mode = Mode::Commit(Input::default()),
            Action::OpenBranch => self.mode = Mode::Branch(Input::default()),
            Action::OpenConfirm => self.mode = Mode::Confirm,
            Action::OpenHelp => self.mode = Mode::Help,
            Action::Edit(op) => {
                if let Some(input) = self.input_mut() {
                    input.apply(op);
                }
            }
            Action::Cancel => self.mode = Mode::Normal,
            Action::Submit => self.submit(),
            Action::Pull => {
                self.report("pull", git::pull());
                self.refresh();
            }
            Action::Push => {
                self.report("push", git::push());
                self.refresh();
            }
        }
    }

    fn move_selection(&mut self, delta: i32) {
        if self.rows.is_empty() {
            return;
        }
        let last = self.rows.len() as i32 - 1;
        self.selected = (self.selected as i32 + delta).clamp(0, last) as usize;
        self.reload_diff();
    }

    fn toggle_focus(&mut self) {
        self.focus = match self.focus {
            Focus::Sidebar => Focus::Diff,
            Focus::Diff => Focus::Sidebar,
        };
    }

    fn enter_row(&mut self) {
        match self.rows.get(self.selected).map(|r| (r.id, r.kind)) {
            Some((id, RowKind::Dir)) => self.set_collapsed(id, !self.collapsed.contains(&id)),
            Some((_, RowKind::File { .. })) => self.focus = Focus::Diff,
            None => {}
        }
    }

    fn collapse(&mut self) {
        let Some(row) = self.rows.get(self.selected).cloned() else {
            return;
        };
        if matches!(row.kind, RowKind::Dir) && row.expanded {
            self.set_collapsed(row.id, true);
        } else if row.depth > 0
            && let Some(parent) = (0..self.selected)
                .rev()
                .find(|&i| self.rows[i].depth < row.depth)
        {
            self.selected = parent;
            self.reload_diff();
        }
    }

    fn expand(&mut self) {
        let Some(row) = self.rows.get(self.selected).cloned() else {
            return;
        };
        if !matches!(row.kind, RowKind::Dir) {
            return;
        }
        if row.expanded {
            if self
                .rows
                .get(self.selected + 1)
                .is_some_and(|c| c.depth > row.depth)
            {
                self.selected += 1;
                self.reload_diff();
            }
        } else {
            self.set_collapsed(row.id, false);
        }
    }

    fn set_collapsed(&mut self, id: usize, collapsed: bool) {
        if collapsed {
            self.collapsed.insert(id);
        } else {
            self.collapsed.remove(&id);
        }
        self.reflatten();
    }

    fn reflatten(&mut self) {
        let keep = self.rows.get(self.selected).map(|r| r.id);
        self.rows = tree::flatten(&self.tree, &self.collapsed);
        self.selected = keep
            .and_then(|id| self.rows.iter().position(|r| r.id == id))
            .unwrap_or_else(|| self.selected.min(self.rows.len().saturating_sub(1)));
        self.reload_diff();
    }

    fn input_mut(&mut self) -> Option<&mut Input> {
        match &mut self.mode {
            Mode::Commit(input) | Mode::Branch(input) => Some(input),
            _ => None,
        }
    }

    fn submit(&mut self) {
        match std::mem::replace(&mut self.mode, Mode::Normal) {
            Mode::Commit(input) => self.report_unit("commit", git::commit(&input.value)),
            Mode::Branch(input) => self.report_unit("branch", git::checkout(&input.value)),
            Mode::Confirm => self.report_unit("reset", git::reset_hard_clean()),
            Mode::Normal | Mode::Help => {}
        }
        self.refresh();
    }

    fn stage_toggle(&mut self) {
        let Some(row) = self.rows.get(self.selected).cloned() else {
            return;
        };
        let result = match row.kind {
            RowKind::File { index } => {
                let file = &self.status.files[index];
                if file.staged {
                    git::unstage(&file.path)
                } else {
                    git::stage(&file.path)
                }
            }
            RowKind::Dir => {
                if self.files_under(&row.path).any(|f| !f.staged) {
                    git::stage(&row.path)
                } else {
                    git::unstage(&row.path)
                }
            }
        };
        if let Err(e) = result {
            self.message = e.to_string();
        }
        self.refresh();
    }

    fn files_under<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a FileEntry> {
        let dir = format!("{prefix}/");
        self.status
            .files
            .iter()
            .filter(move |f| f.path == prefix || f.path.starts_with(&dir))
    }

    fn refresh(&mut self) {
        match git::status_raw() {
            Ok(bytes) => {
                self.status = status::parse(&bytes);
                self.tree = tree::build(&self.status.files);
                self.rows = tree::flatten(&self.tree, &self.collapsed);
                if self.selected >= self.rows.len() {
                    self.selected = self.rows.len().saturating_sub(1);
                }
                self.reload_diff();
            }
            Err(e) => self.message = e.to_string(),
        }
    }

    fn reload_diff(&mut self) {
        self.diff_scroll = 0;
        self.diff_rows = match self
            .rows
            .get(self.selected)
            .map(|r| (r.kind, r.path.clone()))
        {
            Some((RowKind::File { index }, _)) => {
                let raw = self
                    .status
                    .files
                    .get(index)
                    .map(entry_diff)
                    .unwrap_or_default();
                diff::parse(&raw, false)
            }
            Some((RowKind::Dir, path)) => diff::parse(&self.folder_diff(&path), true),
            None => Vec::new(),
        };
    }

    fn folder_diff(&self, prefix: &str) -> String {
        let mut out = String::new();
        for file in self.files_under(prefix) {
            let chunk = entry_diff(file);
            out.push_str(&chunk);
            if !chunk.is_empty() && !chunk.ends_with('\n') {
                out.push('\n');
            }
        }
        out
    }

    fn report(&mut self, label: &str, result: Result<String>) {
        self.message = match result {
            Ok(text) => format!("{label}: {text}"),
            Err(e) => format!("{label}: {e}"),
        };
    }

    fn report_unit(&mut self, label: &str, result: Result<()>) {
        self.message = match result {
            Ok(()) => format!("{label}: ok"),
            Err(e) => format!("{label}: {e}"),
        };
    }
}

fn entry_diff(file: &FileEntry) -> String {
    if file.untracked {
        git::diff_untracked(&file.path)
    } else if file.unstaged {
        git::diff(&file.path, false)
    } else {
        git::diff(&file.path, true)
    }
}