use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{layout::Rect, widgets::ListState};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action;
use crate::git::graph::{BranchSegment, GraphBuilder, GraphOptions, GraphRow};
use crate::theme::Theme;
mod component;
#[cfg(test)]
mod tests;
struct GraphLoadGuard {
generation: u64,
tx: UnboundedSender<Action>,
completed: bool,
}
impl GraphLoadGuard {
fn new(generation: u64, tx: UnboundedSender<Action>) -> Self {
Self {
generation,
tx,
completed: false,
}
}
fn complete(mut self) {
self.completed = true;
}
}
impl Drop for GraphLoadGuard {
fn drop(&mut self) {
if !self.completed {
let _ = self.tx.send(Action::GraphLoadAborted {
generation: self.generation,
});
}
}
}
struct CommitDetail {
oid: String,
message: String,
files: Vec<(String, String)>,
file_state: ListState,
diff_content: Option<String>,
diff_scroll: u16,
msg_scroll: u16,
msg_area: Rect,
file_list_area: Rect,
}
struct SearchState {
visible: bool,
input: String,
matches: Vec<usize>,
current_match: Option<usize>,
}
impl SearchState {
fn new() -> Self {
Self {
visible: false,
input: String::new(),
matches: Vec::new(),
current_match: None,
}
}
fn clear(&mut self) {
self.visible = false;
self.input.clear();
self.matches.clear();
self.current_match = None;
}
}
const MAX_CONSECUTIVE_ABORTS: u8 = 2;
pub(crate) struct GitGraph {
rows: Vec<GraphRow>,
all_rows: Vec<GraphRow>,
collapsed_branches: std::collections::HashSet<String>,
segments: Vec<BranchSegment>,
row_to_segment: Vec<Option<usize>>,
state: ListState,
repo_name: String,
repo_path: Option<PathBuf>,
loading: bool,
error: Option<String>,
pub focused: bool,
action_tx: Option<UnboundedSender<Action>>,
render_area: Rect,
graph_list_area: Rect,
files_area: Rect,
diff_area: Rect,
commit_detail: Option<CommitDetail>,
pub(crate) graph_options: GraphOptions,
search: SearchState,
h_scroll: usize,
pub horizontal_layout: bool,
needs_reload: bool,
load_in_flight: bool,
consecutive_aborts: u8,
load_generation: u64,
detail_generation: u64,
theme: Arc<Theme>,
}
impl GitGraph {
pub fn new(theme: Arc<Theme>) -> Self {
Self {
rows: Vec::new(),
all_rows: Vec::new(),
collapsed_branches: std::collections::HashSet::new(),
segments: Vec::new(),
row_to_segment: Vec::new(),
state: ListState::default(),
repo_name: String::new(),
repo_path: None,
loading: false,
error: None,
focused: false,
action_tx: None,
render_area: Rect::default(),
graph_list_area: Rect::default(),
files_area: Rect::default(),
diff_area: Rect::default(),
commit_detail: None,
graph_options: GraphOptions::default(),
search: SearchState::new(),
h_scroll: 0,
horizontal_layout: false,
needs_reload: false,
load_in_flight: false,
consecutive_aborts: 0,
load_generation: 0,
detail_generation: 0,
theme,
}
}
pub fn set_theme(&mut self, theme: Arc<Theme>) {
self.theme = theme;
}
pub fn load_repo(&mut self, path: PathBuf, repo_name: &str) {
let is_same_repo = self.repo_path.as_deref() == Some(path.as_path());
if is_same_repo && self.load_in_flight {
self.needs_reload = true;
return;
}
self.repo_name = repo_name.to_string();
self.repo_path = Some(path.clone());
self.error = None;
if !is_same_repo {
self.loading = true;
self.rows.clear();
self.all_rows.clear();
self.state.select(None);
self.commit_detail = None;
self.needs_reload = false;
self.consecutive_aborts = 0;
self.search.clear();
self.collapsed_branches.clear();
self.segments.clear();
self.row_to_segment.clear();
}
let Some(tx) = &self.action_tx else { return };
let tx = tx.clone();
let options = self.graph_options.clone();
self.load_in_flight = true;
self.load_generation += 1;
let load_gen = self.load_generation;
tokio::task::spawn_blocking(move || {
let guard = GraphLoadGuard::new(load_gen, tx.clone());
let builder = GraphBuilder::new();
match builder.build(&path, &options) {
Ok(rows) => {
let oids: Vec<git2::Oid> = rows.iter().map(|r| r.oid).collect();
let _ = tx.send(Action::GraphLoaded {
generation: load_gen,
rows,
});
guard.complete();
if options.show_stats
&& let Ok(stats) = crate::git::commit_files::batch_diff_stats(&path, &oids)
{
let _ = tx.send(Action::DiffStatsLoaded {
generation: load_gen,
stats,
});
}
}
Err(e) => {
let _ = tx.send(Action::GraphError {
generation: load_gen,
message: format!("Failed to load graph: {}", e),
});
guard.complete();
}
}
});
}
pub fn set_error(&mut self, msg: String) {
self.error = Some(msg);
self.loading = false;
self.load_in_flight = false;
}
pub fn set_rows(&mut self, mut rows: Vec<GraphRow>) {
let prev_selected = self.state.selected();
if !self.all_rows.is_empty() {
let old_stats: std::collections::HashMap<git2::Oid, crate::git::graph::DiffStat> = self
.all_rows
.iter()
.filter_map(|r| r.diff_stat.clone().map(|s| (r.oid, s)))
.collect();
for row in &mut rows {
if row.diff_stat.is_none() {
row.diff_stat = old_stats.get(&row.oid).cloned();
}
}
}
self.all_rows = rows;
self.loading = false;
self.load_in_flight = false;
self.consecutive_aborts = 0;
self.recompute_segments();
self.recompute_collapsed_rows();
if !self.display_rows().is_empty() {
let idx = prev_selected
.map(|i| i.min(self.display_rows().len() - 1))
.unwrap_or(0);
self.state.select(Some(idx));
}
if std::mem::take(&mut self.needs_reload) {
self.reload_graph();
}
}
pub fn set_diff_stats(&mut self, stats: Vec<(git2::Oid, crate::git::graph::DiffStat)>) {
let stat_map: std::collections::HashMap<_, _> = stats.into_iter().collect();
for row in &mut self.all_rows {
if let Some(stat) = stat_map.get(&row.oid) {
row.diff_stat = Some(stat.clone());
}
}
self.recompute_collapsed_rows();
}
pub fn set_commit_files(&mut self, oid: String, message: String, files: Vec<(String, String)>) {
let mut file_state = ListState::default();
if !files.is_empty() {
file_state.select(Some(0));
}
self.commit_detail = Some(CommitDetail {
oid,
message,
files,
file_state,
diff_content: None,
diff_scroll: 0,
msg_scroll: 0,
msg_area: Rect::default(),
file_list_area: Rect::default(),
});
}
pub fn set_commit_diff(&mut self, content: String) {
if let Some(ref mut detail) = self.commit_detail {
detail.diff_content = Some(content);
detail.diff_scroll = 0;
}
}
pub fn has_detail(&self) -> bool {
self.commit_detail.is_some()
}
pub fn set_needs_reload(&mut self) {
self.needs_reload = true;
}
pub fn abort_load(&mut self) {
if !self.load_in_flight {
return;
}
self.load_in_flight = false;
self.loading = false;
self.consecutive_aborts = self.consecutive_aborts.saturating_add(1);
if self.consecutive_aborts > MAX_CONSECUTIVE_ABORTS {
self.needs_reload = false;
self.error = Some("Graph build failed repeatedly".to_string());
return;
}
if std::mem::take(&mut self.needs_reload) {
self.reload_graph();
}
}
pub fn current_generation(&self) -> u64 {
self.load_generation
}
pub fn current_detail_generation(&self) -> u64 {
self.detail_generation
}
fn toggle_collapse_selected(&mut self) {
let Some(idx) = self.state.selected() else {
return;
};
let Some(row) = self.display_rows().get(idx) else {
return;
};
let collapsed_key = row.collapsed.as_ref().map(|(k, _)| k.clone());
let row_oid = row.oid;
if let Some(key) = collapsed_key {
self.collapsed_branches.remove(key.as_str());
self.recompute_collapsed_rows();
return;
}
let Some(all_idx) = self.all_rows.iter().position(|r| r.oid == row_oid) else {
return;
};
let Some(Some(seg_idx)) = self.row_to_segment.get(all_idx) else {
return; };
let seg = &self.segments[*seg_idx];
self.collapsed_branches.insert(seg.id.clone());
self.recompute_collapsed_rows();
}
fn expand_all_branches(&mut self) {
if self.collapsed_branches.is_empty() {
return;
}
self.collapsed_branches.clear();
self.recompute_collapsed_rows();
}
fn reload_graph(&mut self) {
if let Some(path) = self.repo_path.clone() {
let name = self.repo_name.clone();
self.load_repo(path, &name);
}
}
fn recompute_segments(&mut self) {
self.segments = crate::git::graph::compute_branch_segments(&self.all_rows);
self.row_to_segment = vec![None; self.all_rows.len()];
for (seg_idx, seg) in self.segments.iter().enumerate() {
for &row_idx in &seg.row_indices {
self.row_to_segment[row_idx] = Some(seg_idx);
}
}
}
fn display_rows(&self) -> &[GraphRow] {
if self.collapsed_branches.is_empty() {
&self.all_rows
} else {
&self.rows
}
}
fn recompute_collapsed_rows(&mut self) {
if self.collapsed_branches.is_empty() {
self.rows.clear();
return;
}
let mut hidden: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut placeholders: Vec<(usize, String, String, usize)> = Vec::new();
for seg in &self.segments {
if !self.collapsed_branches.contains(&seg.id) {
continue;
}
for &row_idx in &seg.row_indices {
hidden.insert(row_idx);
}
let tip_idx = seg.row_indices[0];
placeholders.push((
tip_idx,
seg.id.clone(),
seg.display_name.clone(),
seg.row_indices.len(),
));
}
let mut rows = Vec::new();
for (i, row) in self.all_rows.iter().enumerate() {
if hidden.contains(&i) {
if let Some((_, seg_id, name, count)) =
placeholders.iter().find(|(tip, _, _, _)| *tip == i)
{
let mut placeholder = row.clone();
placeholder.message = format!("\u{25b6} {name} ({count} commits)");
placeholder.short_id = String::new();
placeholder.author = String::new();
placeholder.labels = Vec::new();
placeholder.diff_stat = None;
placeholder.collapsed = Some((seg_id.clone(), *count));
rows.push(placeholder);
}
continue;
}
rows.push(row.clone());
}
self.rows = rows;
}
pub fn selected_text(&self) -> Option<String> {
if let Some(ref detail) = self.commit_detail
&& let Some(idx) = detail.file_state.selected()
&& let Some((_, path)) = detail.files.get(idx)
{
return Some(path.clone());
}
let idx = self.state.selected()?;
let row = self.display_rows().get(idx)?;
Some(format!("{} {}", row.short_id, row.message))
}
pub fn search_visible(&self) -> bool {
self.search.visible
}
pub fn handle_search_key(&mut self, key: KeyEvent) -> Result<Option<Action>> {
match key.code {
KeyCode::Esc => {
self.search.visible = false;
}
KeyCode::Enter => {
self.search.visible = false;
if let Some(&idx) = self.search.matches.first() {
self.search.current_match = Some(0);
self.state.select(Some(idx));
}
}
KeyCode::Backspace => {
self.search.input.pop();
self.update_search_matches();
}
KeyCode::Char(c) => {
self.search.input.push(c);
self.update_search_matches();
}
_ => {}
}
Ok(None)
}
fn update_search_matches(&mut self) {
self.search.current_match = None;
if self.search.input.is_empty() {
self.search.matches.clear();
return;
}
let query = self.search.input.to_lowercase();
let matches: Vec<usize> = self
.display_rows()
.iter()
.enumerate()
.filter(|(_, row)| {
row.message.to_lowercase().contains(&query)
|| row.author.to_lowercase().contains(&query)
|| row.short_id.to_lowercase().contains(&query)
})
.map(|(i, _)| i)
.collect();
if !matches.is_empty() {
self.search.current_match = Some(0);
}
self.search.matches = matches;
}
fn search_next(&mut self) {
if self.search.matches.is_empty() {
return;
}
let next = match self.search.current_match {
Some(i) => (i + 1) % self.search.matches.len(),
None => 0,
};
self.search.current_match = Some(next);
self.state.select(Some(self.search.matches[next]));
}
fn search_prev(&mut self) {
if self.search.matches.is_empty() {
return;
}
let prev = match self.search.current_match {
Some(0) | None => self.search.matches.len() - 1,
Some(i) => i - 1,
};
self.search.current_match = Some(prev);
self.state.select(Some(self.search.matches[prev]));
}
fn select_next(&mut self) {
if self.display_rows().is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => (i + 1).min(self.display_rows().len() - 1),
None => 0,
};
self.state.select(Some(i));
}
fn select_prev(&mut self) {
if self.display_rows().is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.state.select(Some(i));
}
fn try_show_commit_files(&mut self) -> Option<Action> {
let idx = self.state.selected()?;
let oid = self.display_rows().get(idx)?.oid.to_string();
let repo_path = self.repo_path.clone()?;
self.detail_generation += 1;
Some(Action::ShowCommitFiles { repo_path, oid })
}
fn try_show_commit_diff(&mut self) -> Option<Action> {
let detail = self.commit_detail.as_ref()?;
let file_idx = detail.file_state.selected()?;
let (_, file_path) = detail.files.get(file_idx)?;
let repo_path = self.repo_path.clone()?;
self.detail_generation += 1;
Some(Action::ShowCommitDiff {
repo_path,
oid: detail.oid.clone(),
file_path: file_path.clone(),
})
}
}