From cff7b3000e645a7148b474431b33f98745e56c37 Mon Sep 17 00:00:00 2001 From: YTAG Date: Mon, 20 Jul 2026 17:32:57 -0700 Subject: [PATCH] feat: add Graph tab with SourceTree-style branch lanes Walk all local/remote/tag tips and draw colored Unicode lanes beside commits so branch topology is visible next to Stashes. --- CHANGELOG.md | 1 + asyncgit/src/sync/graph_lanes.rs | 337 +++++++++++++++++++++++++++++++ asyncgit/src/sync/graph_log.rs | 220 ++++++++++++++++++++ asyncgit/src/sync/mod.rs | 4 + src/app.rs | 14 +- src/components/commitlist.rs | 55 ++++- src/keys/key_list.rs | 2 + src/queue.rs | 1 + src/strings.rs | 12 +- src/tabs/mod.rs | 3 + src/tabs/refgraph.rs | 187 +++++++++++++++++ src/ui/style.rs | 14 ++ 12 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 asyncgit/src/sync/graph_lanes.rs create mode 100644 asyncgit/src/sync/graph_log.rs create mode 100644 src/tabs/refgraph.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..2846612f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* Graph tab with SourceTree-style colored branch/tag lanes (`Graph [6]`) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/asyncgit/src/sync/graph_lanes.rs b/asyncgit/src/sync/graph_lanes.rs new file mode 100644 index 0000000000..60056fae8b --- /dev/null +++ b/asyncgit/src/sync/graph_lanes.rs @@ -0,0 +1,337 @@ +//! Assign SourceTree-style lane glyphs to a newest-first commit list. + +use super::graph_log::GraphCommit; +use super::CommitId; + +/// One drawn cell in the lane column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphCell { + /// Glyph to render. + pub ch: char, + /// Stable color index for this lane (cycle in the UI theme). + pub color: usize, +} + +/// Lane glyphs for a single commit row. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GraphRow { + /// Cells left-to-right. + pub cells: Vec, + /// Lane index of this commit's node. + pub commit_lane: usize, +} + +/// Compute lane rows for `commits` (must be newest-first, as from +/// [`super::graph_log::get_graph_commits`]). +#[must_use] +pub fn assign_lanes(commits: &[GraphCommit]) -> Vec { + let mut reserved: Vec> = Vec::new(); + let mut colors: Vec = Vec::new(); + let mut next_color = 0_usize; + let mut rows = Vec::with_capacity(commits.len()); + + for commit in commits { + let matching: Vec = reserved + .iter() + .enumerate() + .filter_map(|(i, id)| { + (*id == Some(commit.id)).then_some(i) + }) + .collect(); + + let commit_lane = matching.first().copied().unwrap_or_else(|| { + open_lane( + &mut reserved, + &mut colors, + &mut next_color, + commit.id, + ) + }); + + // Ensure colors vec matches reserved length. + while colors.len() < reserved.len() { + colors.push(next_color); + next_color = next_color.wrapping_add(1); + } + + let parent_count = commit.parents.len(); + let mut cells = draw_cells( + &reserved, + &colors, + commit_lane, + &matching, + parent_count, + ); + + // Update reserved lanes for following rows. + for &lane in &matching { + if let Some(slot) = reserved.get_mut(lane) { + *slot = None; + } + } + + if let Some((first, rest)) = commit.parents.split_first() { + let first_already = reserved + .iter() + .position(|id| *id == Some(*first)); + + match first_already { + Some(existing) if existing != commit_lane => { + // Merge into an existing lane; free this one. + if let Some(slot) = reserved.get_mut(commit_lane) + { + *slot = None; + } + draw_merge_connector( + &mut cells, + &colors, + commit_lane, + existing, + ); + } + _ => { + if let Some(slot) = reserved.get_mut(commit_lane) + { + *slot = Some(*first); + } + } + } + + for parent in rest { + if reserved.iter().any(|id| *id == Some(*parent)) { + continue; + } + let lane = open_lane( + &mut reserved, + &mut colors, + &mut next_color, + *parent, + ); + draw_fork_connector( + &mut cells, + &colors, + commit_lane, + lane, + ); + } + } + + while reserved.last() == Some(&None) { + reserved.pop(); + colors.pop(); + } + + while cells.last().is_some_and(|c| c.ch == ' ') { + cells.pop(); + } + + rows.push(GraphRow { + cells, + commit_lane, + }); + } + + rows +} + +fn open_lane( + reserved: &mut Vec>, + colors: &mut Vec, + next_color: &mut usize, + id: CommitId, +) -> usize { + if let Some(free) = reserved.iter().position(Option::is_none) { + reserved[free] = Some(id); + free + } else { + let lane = reserved.len(); + reserved.push(Some(id)); + colors.push(*next_color); + *next_color = next_color.wrapping_add(1); + lane + } +} + +fn draw_cells( + reserved: &[Option], + colors: &[usize], + commit_lane: usize, + matching: &[usize], + _parent_count: usize, +) -> Vec { + let width = reserved.len().max(commit_lane + 1); + let mut cells = Vec::with_capacity(width * 2); + + for i in 0..width { + let color = colors.get(i).copied().unwrap_or(0); + let ch = if i == commit_lane { + '●' + } else if matching.contains(&i) { + // Secondary merge into this commit (refined below). + '╯' + } else if reserved.get(i).is_some_and(Option::is_some) { + '│' + } else { + ' ' + }; + cells.push(GraphCell { ch, color }); + cells.push(GraphCell { + ch: ' ', + color: 0, + }); + } + + // Horizontal connectors from secondary matching lanes to commit. + for &lane in matching.iter().skip(1) { + let color = colors.get(lane).copied().unwrap_or(0); + fill_horizontal(&mut cells, colors, commit_lane, lane, color); + let idx = lane * 2; + if let Some(cell) = cells.get_mut(idx) { + cell.ch = if lane > commit_lane { '╯' } else { '╰' }; + cell.color = color; + } + } + + cells +} + +fn draw_merge_connector( + cells: &mut Vec, + colors: &[usize], + from: usize, + to: usize, +) { + let color = colors.get(to).copied().unwrap_or(0); + ensure_width(cells, to.max(from) + 1); + fill_horizontal(cells, colors, from, to, color); +} + +fn draw_fork_connector( + cells: &mut Vec, + colors: &[usize], + from: usize, + to: usize, +) { + let color = colors.get(to).copied().unwrap_or(0); + ensure_width(cells, to.max(from) + 1); + fill_horizontal(cells, colors, from, to, color); + let idx = to * 2; + if let Some(cell) = cells.get_mut(idx) { + cell.ch = if to > from { '╮' } else { '╭' }; + cell.color = color; + } +} + +fn ensure_width(cells: &mut Vec, lanes: usize) { + while cells.len() < lanes * 2 { + cells.push(GraphCell { + ch: ' ', + color: 0, + }); + cells.push(GraphCell { + ch: ' ', + color: 0, + }); + } +} + +fn fill_horizontal( + cells: &mut [GraphCell], + _colors: &[usize], + from: usize, + to: usize, + color: usize, +) { + let left = from.min(to); + let right = from.max(to); + for i in left..=right { + let idx = i * 2; + if idx >= cells.len() { + break; + } + if i == from { + continue; + } + if cells[idx].ch == ' ' || cells[idx].ch == '─' { + cells[idx] = GraphCell { ch: '─', color }; + if idx + 1 < cells.len() { + cells[idx + 1] = GraphCell { ch: '─', color }; + } + } + } +} + +/// Render a row as a plain string (tests / debugging). +#[cfg(test)] +#[must_use] +pub fn row_to_string(row: &GraphRow) -> String { + row.cells.iter().map(|c| c.ch).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use git2::Oid; + + fn cid(n: u8) -> CommitId { + let mut bytes = [0_u8; 20]; + bytes[19] = n; + CommitId::new(Oid::from_bytes(&bytes).unwrap()) + } + + #[test] + fn test_linear_history_single_lane() { + let a = cid(1); + let b = cid(2); + let c = cid(3); + let commits = vec![ + GraphCommit { + id: a, + parents: vec![b], + }, + GraphCommit { + id: b, + parents: vec![c], + }, + GraphCommit { + id: c, + parents: vec![], + }, + ]; + let rows = assign_lanes(&commits); + assert_eq!(rows.len(), 3); + assert!(row_to_string(&rows[0]).contains('●')); + assert_eq!(rows[0].commit_lane, 0); + assert_eq!(rows[1].commit_lane, 0); + assert_eq!(rows[2].commit_lane, 0); + } + + #[test] + fn test_diverged_branches_use_two_lanes() { + let tip_a = cid(1); + let tip_b = cid(2); + let base = cid(3); + let commits = vec![ + GraphCommit { + id: tip_a, + parents: vec![base], + }, + GraphCommit { + id: tip_b, + parents: vec![base], + }, + GraphCommit { + id: base, + parents: vec![], + }, + ]; + let rows = assign_lanes(&commits); + assert_eq!(rows.len(), 3); + assert!( + rows.iter().any(|r| r.commit_lane >= 1), + "expected a second lane for diverged tips, got {:?}", + rows.iter().map(|r| r.commit_lane).collect::>() + ); + assert!(row_to_string(&rows[2]).contains('●')); + } +} diff --git a/asyncgit/src/sync/graph_log.rs b/asyncgit/src/sync/graph_log.rs new file mode 100644 index 0000000000..eb8ace5a6b --- /dev/null +++ b/asyncgit/src/sync/graph_log.rs @@ -0,0 +1,220 @@ +//! Walk commits reachable from all local/remote branches, tags, and HEAD. + +use super::{ + branch::get_branches_info, tags::get_tags, CommitId, RepoPath, +}; +use crate::{error::Result, sync::repository::repo}; +use git2::{Commit, Oid, Repository}; +use scopetime::scope_time; +use std::{ + cmp::Ordering, + collections::{BinaryHeap, HashSet}, +}; + +/// A commit plus its parent ids (first parent first). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphCommit { + /// + pub id: CommitId, + /// + pub parents: Vec, +} + +struct TimeOrderedCommit<'a>(Commit<'a>); + +impl Eq for TimeOrderedCommit<'_> {} + +impl PartialEq for TimeOrderedCommit<'_> { + fn eq(&self, other: &Self) -> bool { + self.0.time().eq(&other.0.time()) + } +} + +impl PartialOrd for TimeOrderedCommit<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimeOrderedCommit<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.0.time().cmp(&other.0.time()) + } +} + +/// Collect tip commit ids from HEAD, local/remote branches, and tags. +pub fn get_graph_tips(repo_path: &RepoPath) -> Result> { + scope_time!("get_graph_tips"); + + let mut tips = HashSet::new(); + + if let Ok(head) = super::utils::get_head(repo_path) { + tips.insert(head); + } + + if let Ok(local) = get_branches_info(repo_path, true) { + for b in local { + tips.insert(b.top_commit); + } + } + + if let Ok(remote) = get_branches_info(repo_path, false) { + for b in remote { + tips.insert(b.top_commit); + } + } + + if let Ok(tags) = get_tags(repo_path) { + for id in tags.keys() { + tips.insert(*id); + } + } + + Ok(tips.into_iter().collect()) +} + +/// Walk the commit graph from all tips, newest first, up to `limit`. +pub fn get_graph_commits( + repo_path: &RepoPath, + limit: usize, +) -> Result> { + scope_time!("get_graph_commits"); + + let repo = repo(repo_path)?; + let tips = get_graph_tips(repo_path)?; + let mut walker = GraphLogWalker::new(&repo, &tips, limit)?; + let mut out = Vec::new(); + walker.read(&mut out)?; + Ok(out) +} + +struct GraphLogWalker<'a> { + commits: BinaryHeap>, + visited: HashSet, + limit: usize, +} + +impl<'a> GraphLogWalker<'a> { + fn new( + repo: &'a Repository, + tips: &[CommitId], + limit: usize, + ) -> Result { + let mut commits = BinaryHeap::with_capacity(tips.len().max(1)); + let mut visited = HashSet::with_capacity(1000); + + for tip in tips { + if let Ok(c) = repo.find_commit((*tip).into()) { + if visited.insert(c.id()) { + commits.push(TimeOrderedCommit(c)); + } + } + } + + // Fall back to HEAD if no tips resolved (empty repo edge cases). + if commits.is_empty() { + if let Ok(c) = repo.head()?.peel_to_commit() { + if visited.insert(c.id()) { + commits.push(TimeOrderedCommit(c)); + } + } + } + + Ok(Self { + commits, + visited, + limit, + }) + } + + fn read(&mut self, out: &mut Vec) -> Result { + let mut count = 0_usize; + + while let Some(c) = self.commits.pop() { + let parents: Vec = c + .0 + .parents() + .map(|p| { + let id = p.id(); + if self.visited.insert(id) { + self.commits.push(TimeOrderedCommit(p)); + } + id.into() + }) + .collect(); + + out.push(GraphCommit { + id: c.0.id().into(), + parents, + }); + + count += 1; + if count == self.limit { + break; + } + } + + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::{ + create_branch, stage_add_file, tests::repo_init_empty, commit, + }; + use pretty_assertions::assert_eq; + use std::{fs::File, io::Write, path::Path}; + + #[test] + fn test_graph_includes_side_branch() { + let file_path = Path::new("foo"); + let (_td, repo) = repo_init_empty().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + File::create(root.join(file_path)) + .unwrap() + .write_all(b"a") + .unwrap(); + stage_add_file(repo_path, file_path).unwrap(); + let c1 = commit(repo_path, "base").unwrap(); + + let main_ref = repo + .head() + .unwrap() + .name() + .unwrap() + .to_string(); + + create_branch(repo_path, "feature").unwrap(); + File::create(root.join(file_path)) + .unwrap() + .write_all(b"b") + .unwrap(); + stage_add_file(repo_path, file_path).unwrap(); + let c2 = commit(repo_path, "feature-commit").unwrap(); + + repo.set_head(&main_ref).unwrap(); + repo.checkout_head(Some( + git2::build::CheckoutBuilder::new().force(), + )) + .unwrap(); + File::create(root.join(file_path)) + .unwrap() + .write_all(b"c") + .unwrap(); + stage_add_file(repo_path, file_path).unwrap(); + let c3 = commit(repo_path, "main-commit").unwrap(); + + let graph = get_graph_commits(repo_path, 100).unwrap(); + let ids: HashSet<_> = graph.iter().map(|c| c.id).collect(); + + assert!(ids.contains(&c1)); + assert!(ids.contains(&c2)); + assert!(ids.contains(&c3)); + assert_eq!(graph.len(), 3); + } +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..df0988b907 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -17,6 +17,8 @@ pub mod diff; mod hooks; mod hunks; mod ignore; +mod graph_lanes; +mod graph_log; mod logwalker; mod merge; mod patches; @@ -70,6 +72,8 @@ pub use hooks::{ hooks_pre_push, hooks_prepare_commit_msg, HookResult, PrePushTarget, PrepareCommitMsgSource, }; +pub use graph_lanes::{assign_lanes, GraphCell, GraphRow}; +pub use graph_log::{get_graph_commits, get_graph_tips, GraphCommit}; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; diff --git a/src/app.rs b/src/app.rs index ddb157af3f..710fafd59d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,7 +28,7 @@ use crate::{ }, setup_popups, strings::{self, ellipsis_trim_start, order}, - tabs::{FilesTab, Revlog, StashList, Stashing, Status}, + tabs::{FilesTab, RefGraph, Revlog, StashList, Stashing, Status}, try_or_popup, ui::style::{SharedTheme, Theme}, AsyncAppNotification, AsyncNotification, @@ -106,6 +106,7 @@ pub struct App { status_tab: Status, stashing_tab: Stashing, stashlist_tab: StashList, + graph_tab: RefGraph, files_tab: FilesTab, queue: Queue, theme: SharedTheme, @@ -234,6 +235,7 @@ impl App { status_tab: Status::new(&env), stashing_tab: Stashing::new(&env), stashlist_tab: StashList::new(&env), + graph_tab: RefGraph::new(&env), files_tab: FilesTab::new(&env, select_file), checkout_option_popup: CheckoutOptionPopup::new(&env), goto_line_popup: GotoLinePopup::new(&env), @@ -293,6 +295,7 @@ impl App { 2 => self.files_tab.draw(f, chunks_main[1])?, 3 => self.stashing_tab.draw(f, chunks_main[1])?, 4 => self.stashlist_tab.draw(f, chunks_main[1])?, + 5 => self.graph_tab.draw(f, chunks_main[1])?, _ => bail!("unknown tab"), } } @@ -409,6 +412,7 @@ impl App { self.files_tab.update()?; self.stashing_tab.update()?; self.stashlist_tab.update()?; + self.graph_tab.update()?; self.reset_popup.update()?; self.update_commands(); @@ -530,7 +534,8 @@ impl App { status_tab, files_tab, stashing_tab, - stashlist_tab + stashlist_tab, + graph_tab ] ); @@ -601,6 +606,7 @@ impl App { &mut self.files_tab, &mut self.stashing_tab, &mut self.stashlist_tab, + &mut self.graph_tab, ] } @@ -626,6 +632,8 @@ impl App { self.switch_to_tab(&AppTabs::Stashing)?; } else if key_match(k, self.key_config.keys.tab_stashes) { self.switch_to_tab(&AppTabs::Stashlist)?; + } else if key_match(k, self.key_config.keys.tab_graph) { + self.switch_to_tab(&AppTabs::Graph)?; } Ok(()) @@ -654,6 +662,7 @@ impl App { AppTabs::Files => self.set_tab(2)?, AppTabs::Stashing => self.set_tab(3)?, AppTabs::Stashlist => self.set_tab(4)?, + AppTabs::Graph => self.set_tab(5)?, } Ok(()) } @@ -1173,6 +1182,7 @@ impl App { Span::raw(strings::tab_files(&self.key_config)), Span::raw(strings::tab_stashing(&self.key_config)), Span::raw(strings::tab_stashes(&self.key_config)), + Span::raw(strings::tab_graph(&self.key_config)), ]; let divider = strings::tab_divider(&self.key_config); diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e97754ecb4..a3503c336b 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -15,7 +15,7 @@ use crate::{ use anyhow::Result; use asyncgit::sync::{ self, checkout_commit, BranchDetails, BranchInfo, CommitId, - RepoPathRef, Tags, + GraphRow, RepoPathRef, Tags, }; use chrono::{DateTime, Local}; use crossterm::event::Event; @@ -53,6 +53,8 @@ pub struct CommitList { tags: Option, local_branches: BTreeMap>, remote_branches: BTreeMap>, + /// Optional SourceTree-style lane glyphs keyed by commit id. + graph_rows: Option>, current_size: Cell>, scroll_top: Cell, theme: SharedTheme, @@ -75,6 +77,7 @@ impl CommitList { tags: None, local_branches: BTreeMap::default(), remote_branches: BTreeMap::default(), + graph_rows: None, current_size: Cell::new(None), scroll_top: Cell::new(0), theme: env.theme.clone(), @@ -95,6 +98,11 @@ impl CommitList { self.commits.clear(); } + /// + pub fn is_empty(&self) -> bool { + self.commits.is_empty() + } + /// pub fn copy_items(&self) -> Vec { self.commits.iter().copied().collect_vec() @@ -105,6 +113,14 @@ impl CommitList { self.tags = Some(tags); } + /// Attach precomputed graph lanes (or clear with `None`). + pub fn set_graph_rows( + &mut self, + graph_rows: Option>, + ) { + self.graph_rows = graph_rows; + } + /// pub fn selected_entry(&self) -> Option<&LogEntry> { self.items.iter().nth( @@ -451,9 +467,12 @@ impl CommitList { width: usize, now: DateTime, marked: Option, + graph: Option<&GraphRow>, ) -> Line<'a> { let mut txt: Vec = Vec::with_capacity( - ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, + ELEMENTS_PER_LINE + + if marked.is_some() { 2 } else { 0 } + + graph.map_or(0, |g| g.cells.len()), ); let normal = !self.items.highlighting() @@ -482,6 +501,22 @@ impl CommitList { txt.push(splitter.clone()); } + // SourceTree-style lane graph + if let Some(graph) = graph { + for cell in &graph.cells { + let style = if normal { + theme.graph_lane(cell.color, selected) + } else { + theme.commit_unhighlighted() + }; + txt.push(Span::styled( + Cow::from(cell.ch.to_string()), + style, + )); + } + txt.push(splitter.clone()); + } + let style_hash = if normal { theme.commit_hash(selected) } else { @@ -595,7 +630,18 @@ impl CommitList { local_branch .iter() .map(|local_branch| { - format!("{{{0}}}", local_branch.name) + let head = match &local_branch.details { + BranchDetails::Local(details) + if details.is_head => + { + "*" + } + _ => "", + }; + format!( + "{{{head}{0}}}", + local_branch.name + ) }) .join(" ") }); @@ -616,6 +662,9 @@ impl CommitList { width, now, marked, + self.graph_rows + .as_ref() + .and_then(|rows| rows.get(&e.id)), )); } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..eca06791b7 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -42,6 +42,7 @@ pub struct KeysList { pub tab_files: GituiKeyEvent, pub tab_stashing: GituiKeyEvent, pub tab_stashes: GituiKeyEvent, + pub tab_graph: GituiKeyEvent, pub tab_toggle: GituiKeyEvent, pub tab_toggle_reverse: GituiKeyEvent, pub toggle_workarea: GituiKeyEvent, @@ -140,6 +141,7 @@ impl Default for KeysList { tab_files: GituiKeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty()), tab_stashing: GituiKeyEvent::new(KeyCode::Char('4'), KeyModifiers::empty()), tab_stashes: GituiKeyEvent::new(KeyCode::Char('5'), KeyModifiers::empty()), + tab_graph: GituiKeyEvent::new(KeyCode::Char('6'), KeyModifiers::empty()), tab_toggle: GituiKeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), tab_toggle_reverse: GituiKeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), toggle_workarea: GituiKeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..39692ddd2c 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -80,6 +80,7 @@ pub enum AppTabs { Files, Stashing, Stashlist, + Graph, } /// diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..cc880047d9 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -91,6 +91,12 @@ pub fn tab_stashes(key_config: &SharedKeyConfig) -> String { key_config.get_hint(key_config.keys.tab_stashes) ) } +pub fn tab_graph(key_config: &SharedKeyConfig) -> String { + format!( + "Graph [{}]", + key_config.get_hint(key_config.keys.tab_graph) + ) +} pub fn tab_divider(_key_config: &SharedKeyConfig) -> String { " | ".to_string() } @@ -328,6 +334,9 @@ pub fn tag_popup_annotation_msg() -> String { pub fn stashlist_title(_key_config: &SharedKeyConfig) -> String { "Stashes".to_string() } +pub fn graph_title(_key_config: &SharedKeyConfig) -> String { + "Graph".to_string() +} pub fn help_title(_key_config: &SharedKeyConfig) -> String { "Help: all commands".to_string() } @@ -577,12 +586,13 @@ pub mod commands { ) -> CommandText { CommandText::new( format!( - "Tab [{}{}{}{}{}]", + "Tab [{}{}{}{}{}{}]", key_config.get_hint(key_config.keys.tab_status), key_config.get_hint(key_config.keys.tab_log), key_config.get_hint(key_config.keys.tab_files), key_config.get_hint(key_config.keys.tab_stashing), key_config.get_hint(key_config.keys.tab_stashes), + key_config.get_hint(key_config.keys.tab_graph), ), "switch top level tabs directly", CMD_GROUP_GENERAL, diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs index ba479ad511..decf873e45 100644 --- a/src/tabs/mod.rs +++ b/src/tabs/mod.rs @@ -7,18 +7,21 @@ ui: - [`FilesTab`]: See content of any file at HEAD. Blame - [`Stashing`]: Managing one stash - [`StashList`]: Managing all stashes +- [`RefGraph`]: SourceTree-style branch/tag commit graph Many of the tabs can expand to show more details. This is done via Enter or right-arrow. To close again, press ESC. */ mod files; +mod refgraph; mod revlog; mod stashing; mod stashlist; mod status; pub use files::FilesTab; +pub use refgraph::RefGraph; pub use revlog::Revlog; pub use stashing::{Stashing, StashingOptions}; pub use stashlist::StashList; diff --git a/src/tabs/refgraph.rs b/src/tabs/refgraph.rs new file mode 100644 index 0000000000..43c5f4a5f8 --- /dev/null +++ b/src/tabs/refgraph.rs @@ -0,0 +1,187 @@ +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, + CommitList, Component, DrawableComponent, EventState, + }, + keys::{key_match, SharedKeyConfig}, + popups::InspectCommitOpen, + queue::{InternalEvent, Queue, StackablePopupOpen}, + strings, +}; +use anyhow::Result; +use asyncgit::sync::{ + self, assign_lanes, get_graph_commits, get_graph_tips, CommitId, + RepoPathRef, +}; +use crossterm::event::Event; +use indexmap::IndexSet; +use std::collections::BTreeMap; + +/// Default commit walk limit for the graph tab. +const GRAPH_LIMIT: usize = 3000; + +/// SourceTree-style commit graph of all branches/tags. +pub struct RefGraph { + repo: RepoPathRef, + list: CommitList, + visible: bool, + queue: Queue, + key_config: SharedKeyConfig, + /// Tip set fingerprint used to skip redundant rebuilds. + tips_fingerprint: u64, +} + +impl RefGraph { + /// + pub fn new(env: &Environment) -> Self { + Self { + visible: false, + list: CommitList::new( + env, + &strings::graph_title(&env.key_config), + ), + queue: env.queue.clone(), + key_config: env.key_config.clone(), + repo: env.repo.clone(), + tips_fingerprint: 0, + } + } + + /// + pub fn update(&mut self) -> Result<()> { + if !self.is_visible() { + return Ok(()); + } + + let repo = self.repo.borrow(); + let tips = get_graph_tips(&repo)?; + let fingerprint = tips_fingerprint(&tips); + if fingerprint == self.tips_fingerprint + && !self.list.is_empty() + { + return Ok(()); + } + + let graph = get_graph_commits(&repo, GRAPH_LIMIT)?; + let lanes = assign_lanes(&graph); + + let mut graph_rows = BTreeMap::new(); + for (commit, row) in graph.iter().zip(lanes.into_iter()) { + graph_rows.insert(commit.id, row); + } + + let commits: IndexSet = + graph.into_iter().map(|c| c.id).collect(); + + self.list.set_graph_rows(Some(graph_rows)); + self.list.set_commits(commits); + + if let Ok(tags) = sync::get_tags(&repo) { + self.list.set_tags(tags); + } + if let Ok(local) = sync::get_branches_info(&repo, true) { + self.list.set_local_branches(local); + } + if let Ok(remote) = sync::get_branches_info(&repo, false) { + self.list.set_remote_branches(remote); + } + + self.tips_fingerprint = fingerprint; + + Ok(()) + } + + fn inspect(&self) { + if let Some(e) = self.list.selected_entry() { + self.queue.push(InternalEvent::OpenPopup( + StackablePopupOpen::InspectCommit( + InspectCommitOpen::new(e.id), + ), + )); + } + } +} + +fn tips_fingerprint(tips: &[CommitId]) -> u64 { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + let mut hasher = DefaultHasher::new(); + let mut sorted = tips.to_vec(); + sorted.sort_unstable(); + sorted.len().hash(&mut hasher); + for id in sorted { + id.hash(&mut hasher); + } + hasher.finish() +} + +impl DrawableComponent for RefGraph { + fn draw( + &self, + f: &mut ratatui::Frame, + rect: ratatui::layout::Rect, + ) -> Result<()> { + self.list.draw(f, rect)?; + Ok(()) + } +} + +impl Component for RefGraph { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.visible || force_all { + self.list.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::stashlist_inspect( + &self.key_config, + ), + self.list.selected_entry().is_some(), + true, + )); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + ev: &crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if self.list.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(k) = ev { + if key_match(k, self.key_config.keys.enter) { + self.inspect(); + return Ok(EventState::Consumed); + } + } + } + + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + self.tips_fingerprint = 0; + self.update()?; + Ok(()) + } +} diff --git a/src/ui/style.rs b/src/ui/style.rs index be21f76b9f..6de8e6d140 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -254,6 +254,20 @@ impl Theme { ) } + /// Color for a commit-graph lane (SourceTree-style). + pub fn graph_lane(&self, lane_color: usize, selected: bool) -> Style { + const PALETTE: [Color; 6] = [ + Color::Cyan, + Color::Yellow, + Color::Magenta, + Color::Green, + Color::LightBlue, + Color::LightRed, + ]; + let fg = PALETTE[lane_color % PALETTE.len()]; + self.apply_select(Style::default().fg(fg), selected) + } + pub fn commit_hash_in_blame( &self, is_blamed_commit: bool,