diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aba19c7e0..6c457eccfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* Now with a graph view! + ## [0.28.0] - 2025-12-14 **discard changes on checkout** diff --git a/Cargo.lock b/Cargo.lock index 564263f91f..adacfdb84d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,7 @@ dependencies = [ "git2", "git2-hooks", "gix", + "im", "invalidstring", "log", "openssl-sys", @@ -168,6 +169,7 @@ dependencies = [ "scopetime", "serde", "serial_test", + "smallvec", "ssh-key", "tempfile", "thiserror", @@ -264,6 +266,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -2181,6 +2192,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "imara-diff" version = "0.1.8" @@ -3012,6 +3037,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -3441,6 +3475,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.9" diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index ff9fb355b4..4febf3b61e 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -30,6 +30,7 @@ gix = { version = "0.78.0", default-features = false, features = [ "mailmap", "status", ] } +im = "15" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} @@ -39,6 +40,7 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +smallvec = "1" ssh-key = { version = "0.6.7", features = ["crypto", "encryption"] } thiserror = "2.0" unicode-truncate = "2.0" diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs new file mode 100644 index 0000000000..34e739397e --- /dev/null +++ b/asyncgit/src/graph/buffer.rs @@ -0,0 +1,235 @@ +use super::chunk::{Chunk, Markers}; +use im::Vector; +use std::collections::BTreeMap; + +#[derive(Clone, Debug)] +pub enum DeltaOp { + Insert { index: usize, item: Option }, + Remove { index: usize }, + Replace { index: usize, new: Option }, +} + +#[derive(Clone, Debug)] +pub struct Delta(pub Vec); + +const CHECKPOINT_INTERVAL: usize = 100; + +pub struct Buffer { + pub current: Vector>, + pub deltas: Vec, + pub checkpoints: BTreeMap>>, + mergers: Vec, + pending_delta: Vec, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + pub fn new() -> Self { + Self { + current: Vector::new(), + deltas: Vec::new(), + checkpoints: BTreeMap::new(), + mergers: Vec::new(), + pending_delta: Vec::new(), + } + } + + pub fn merger(&mut self, alias: u32) { + self.mergers.push(alias); + } + + pub fn update(&mut self, new_chunk: &Chunk) { + self.pending_delta.clear(); + + let mut empty_lanes: Vec = self + .current + .iter() + .enumerate() + .filter_map(|(i, c)| c.is_none().then_some(i)) + .collect(); + + // sort descending so we can pop the lowest index first + empty_lanes.sort_unstable_by(|a, b| b.cmp(a)); + + let found_idx = if new_chunk.alias.is_some() { + self.current.iter().enumerate().find_map(|(i, c)| { + c.as_ref().and_then(|c| { + (c.parent_a == new_chunk.alias).then_some(i) + }) + }) + } else { + None + }; + + if let Some(idx) = found_idx { + self.record_replace(idx, Some(new_chunk.clone())); + } else if let Some(empty_idx) = empty_lanes.pop() { + self.record_replace(empty_idx, Some(new_chunk.clone())); + } else { + self.record_insert( + self.current.len(), + Some(new_chunk.clone()), + ); + } + + let current_length = self.current.len(); + for index in 0..current_length { + if Some(index) == found_idx { + continue; + } + if found_idx.is_none() && index == current_length - 1 { + continue; + } + + if let Some(mut c) = self.current[index].clone() { + let changed = new_chunk.alias.is_some_and(|alias| { + let a = c.parent_a == Some(alias); + let b = c.parent_b == Some(alias); + if a { + c.parent_a = None; + } + if b { + c.parent_b = None; + } + a || b + }); + + if changed { + if c.parent_a.is_none() && c.parent_b.is_none() { + self.record_replace(index, None); + } else { + self.record_replace(index, Some(c)); + } + } + } + } + + while let Some(alias) = self.mergers.pop() { + if let Some(index) = self.current.iter().position(|c| { + c.as_ref() + .is_some_and(|chunk| chunk.alias == Some(alias)) + }) { + if let Some(mut c) = self.current[index].clone() { + let parent_b = c.parent_b; + c.parent_b = None; + self.record_replace(index, Some(c)); + + let new_lane = Chunk { + alias: None, + parent_a: parent_b, + parent_b: None, + marker: Markers::Commit, + }; + + if let Some(empty_idx) = empty_lanes.pop() { + self.record_replace( + empty_idx, + Some(new_lane), + ); + } else { + self.record_insert( + self.current.len(), + Some(new_lane), + ); + } + } + } + } + + while self.current.last().is_some_and(Option::is_none) { + self.record_remove(self.current.len() - 1); + } + + let delta = Delta(self.pending_delta.clone()); + self.deltas.push(delta); + + let current_step = self.deltas.len(); + if current_step > 0 && current_step % CHECKPOINT_INTERVAL == 0 + { + self.checkpoints + .insert(current_step - 1, self.current.clone()); + } + } + + fn record_replace(&mut self, index: usize, new: Option) { + self.pending_delta.push(DeltaOp::Replace { + index, + new: new.clone(), + }); + self.current.set(index, new); + } + + fn record_insert(&mut self, index: usize, item: Option) { + self.pending_delta.push(DeltaOp::Insert { + index, + item: item.clone(), + }); + self.current.insert(index, item); + } + + fn record_remove(&mut self, index: usize) { + self.pending_delta.push(DeltaOp::Remove { index }); + self.current.remove(index); + } + + pub fn decompress( + &self, + start: usize, + end: usize, + ) -> Vec>> { + let (current_index, mut state) = + self.checkpoints.range(..=start).next_back().map_or_else( + || (None, Vector::new()), + |(&i, s)| (Some(i), s.clone()), + ); + + let mut history = + Vec::with_capacity(end.saturating_sub(start) + 1); + + if let Some(index) = current_index { + if index >= start && index <= end { + history.push(state.clone()); + } + } + + let loop_start = current_index.map_or(0, |i| i + 1); + + for delta_index in loop_start..=end { + if let Some(delta) = self.deltas.get(delta_index) { + Self::apply_delta_to_state(&mut state, delta); + + if delta_index >= start { + history.push(state.clone()); + } + } else { + break; + } + } + + history + } + + fn apply_delta_to_state( + state: &mut Vector>, + delta: &Delta, + ) { + for op in &delta.0 { + match op { + DeltaOp::Insert { index, item } => { + state.insert(*index, item.clone()); + } + DeltaOp::Remove { index } => { + state.remove(*index); + } + DeltaOp::Replace { index, new } => { + state.set(*index, new.clone()); + } + } + } + } +} diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs new file mode 100644 index 0000000000..fb0adacf13 --- /dev/null +++ b/asyncgit/src/graph/chunk.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Markers { + Uncommitted, + Commit, +} + +#[derive(Clone, Debug)] +pub struct Chunk { + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, + pub marker: Markers, +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs new file mode 100644 index 0000000000..1ca9b16084 --- /dev/null +++ b/asyncgit/src/graph/mod.rs @@ -0,0 +1,54 @@ +pub mod buffer; +pub mod chunk; +pub mod oids; +pub mod walker; + +pub use walker::GraphWalker; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnType { + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + MergeBridgeStart, + MergeBridgeMid, + MergeBridgeEnd, + BranchDown, + BranchUp, + BranchUpRight, +} + +#[derive(Clone, Debug, Default)] +pub struct GraphRow { + /// Number of active lanes at this commit row + pub lane_count: usize, + + /// Which lane index this commit sits on + pub commit_lane: usize, + + /// Whether this is a merge commit (two parents) + pub is_merge: bool, + + /// Whether this commit is a branch tip + pub is_branch_tip: bool, + + /// Whether this commit has stash marker + pub is_stash: bool, + + /// Connections emitted per lane: + /// None = empty space + /// Some((ConnType, `color_index`)) = draw this connector in this color + pub lanes: Vec>, + + /// Horizontal merge bridge: if this commit merges rightward, + /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across + pub merge_bridge: Option<(usize, usize)>, + + /// Horizontal branch bridges: if this commit spawns branches, + /// spans to draw ─ ╭ ╮ across + pub branches: Vec<(usize, usize)>, +} diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs new file mode 100644 index 0000000000..0dbecdf441 --- /dev/null +++ b/asyncgit/src/graph/oids.rs @@ -0,0 +1,40 @@ +use crate::sync::CommitId; +use std::collections::HashMap; + +pub struct Oids { + /// alias + pub ids: Vec, + + /// `CommitId` to alias + pub aliases: HashMap, +} + +impl Default for Oids { + fn default() -> Self { + Self::new() + } +} + +impl Oids { + pub fn new() -> Self { + Self { + ids: Vec::new(), + aliases: HashMap::new(), + } + } + + pub fn get_or_insert(&mut self, id: &CommitId) -> u32 { + if let Some(&alias) = self.aliases.get(id) { + return alias; + } + let alias = + u32::try_from(self.ids.len()).expect("too many oids"); + self.ids.push(*id); + self.aliases.insert(*id, alias); + alias + } + + pub fn get(&self, id: &CommitId) -> Option { + self.aliases.get(id).copied() + } +} diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs new file mode 100644 index 0000000000..14196fe35f --- /dev/null +++ b/asyncgit/src/graph/walker.rs @@ -0,0 +1,304 @@ +use super::buffer::Buffer; +use super::chunk::{Chunk, Markers}; +use super::oids::Oids; +use super::{ConnType, GraphRow}; +use crate::sync::{CommitId, CommitInfo}; +use im::Vector; +use std::collections::{HashMap, HashSet}; + +pub struct GraphWalker { + pub buffer: Buffer, + pub oids: Oids, + pub branch_lane_map: HashMap, + pub mergers_map: HashMap, +} + +impl Default for GraphWalker { + fn default() -> Self { + Self::new() + } +} + +impl GraphWalker { + pub fn new() -> Self { + Self { + buffer: Buffer::new(), + oids: Oids::new(), + branch_lane_map: HashMap::new(), + mergers_map: HashMap::new(), + } + } + + pub fn process(&mut self, commit: &CommitInfo) { + let alias = self.oids.get_or_insert(&commit.id); + let parent_a = commit + .parents + .first() + .map(|p| self.oids.get_or_insert(p)); + let parent_b = + commit.parents.get(1).map(|p| self.oids.get_or_insert(p)); + + let chunk = Chunk { + alias: Some(alias), + parent_a, + parent_b, + marker: Markers::Commit, + }; + + if let Some(b) = parent_b { + self.mergers_map.insert(alias, b); + } + + if parent_a.is_some() && parent_b.is_some() { + let already_tracked = + self.buffer.current.iter().any(|c| { + c.as_ref().is_some_and(|c| { + c.parent_a == parent_b && c.parent_b.is_none() + }) + }); + if !already_tracked { + self.buffer.merger(alias); + } + } + + self.buffer.update(&chunk); + } + + pub fn snapshot_at( + &self, + global_idx: usize, + ) -> Vector> { + self.buffer + .decompress(global_idx, global_idx) + .into_iter() + .next() + .unwrap_or_default() + } + + pub fn compute_rows( + &self, + commit_range: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Vec { + let end = global_start + commit_range.len().saturating_sub(1); + let snapshots = self.buffer.decompress(global_start, end); + + commit_range + .iter() + .enumerate() + .map(|(index, commit_id)| { + let curr = + snapshots.get(index).cloned().unwrap_or_default(); + let prev = if index > 0 { + snapshots.get(index - 1).cloned() + } else if global_start > 0 { + Some(self.snapshot_at(global_start - 1)) + } else { + None + }; + + self.render_row( + commit_id, + &curr, + prev.as_ref(), + branch_tips, + stashes, + head_id, + ) + }) + .collect() + } + + fn render_row( + &self, + commit_id: &CommitId, + curr: &Vector>, + prev: Option<&Vector>>, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> GraphRow { + let alias = self.oids.get(commit_id); + let commit_lane = curr + .iter() + .position(|c| { + c.as_ref().is_some_and(|chunk| { + alias.is_some() && chunk.alias == alias + }) + }) + .unwrap_or(0); + + let parent_b_alias = + alias.and_then(|a| self.mergers_map.get(&a).copied()); + + let is_merge = parent_b_alias.is_some(); + let is_branch_tip = branch_tips.contains(commit_id); + let is_stash = stashes.contains(commit_id); + + let branching_lanes: Vec = prev + .into_iter() + .flatten() // Unwrapping the optional, returning empty vec when None + .enumerate() + .filter(|(i, pc)| { + pc.is_some() + && curr.get(*i).is_none_or(Option::is_none) + }) + .map(|(i, _)| i) + .collect(); + + let mut lanes = vec![None; curr.len()]; + + let merge_bridge = is_merge + .then(|| { + let target_lane = curr.iter().position(|c| { + c.as_ref().is_some_and(|chunk| { + parent_b_alias.is_some() + && chunk.parent_a == parent_b_alias + }) + }); + target_lane.map(|t| { + if t > commit_lane { + (commit_lane, t) + } else { + (t, commit_lane) + } + }) + }) + .flatten(); + + self.fill_lanes( + &mut lanes, + curr, + alias, + head_id, + is_stash, + is_merge, + is_branch_tip, + &branching_lanes, + ); + + if let Some((from, to)) = merge_bridge { + Self::draw_bridge( + &mut lanes, + from, + to, + commit_lane, + ConnType::MergeBridgeMid, + ConnType::MergeBridgeStart, + ConnType::MergeBridgeEnd, + ); + } + + let mut branches = Vec::new(); + for &branch_lane in &branching_lanes { + let from = std::cmp::min(branch_lane, commit_lane); + let to = std::cmp::max(branch_lane, commit_lane); + branches.push((from, to)); + + if lanes.len() <= to { + lanes.resize(to + 1, None); + } + + Self::draw_bridge( + &mut lanes, + from, + to, + branch_lane, + ConnType::MergeBridgeMid, + ConnType::BranchUp, + ConnType::BranchUpRight, + ); + } + + GraphRow { + lane_count: curr.iter().flatten().count(), + commit_lane, + is_merge, + is_branch_tip, + is_stash, + lanes, + merge_bridge, + branches, + } + } + + #[allow(clippy::too_many_arguments)] + fn fill_lanes( + &self, + lanes: &mut [Option<(ConnType, usize)>], + curr: &Vector>, + alias: Option, + head_id: Option<&CommitId>, + is_stash: bool, + is_merge: bool, + is_branch_tip: bool, + branching_lanes: &[usize], + ) { + for (lane_idx, chunk_item) in curr.iter().enumerate() { + let Some(chunk) = chunk_item.as_ref() else { + if branching_lanes.contains(&lane_idx) { + lanes[lane_idx] = + Some((ConnType::BranchUp, lane_idx % 16)); + } + continue; + }; + + if alias.is_some() && chunk.alias == alias { + let conn_type = + match (is_stash, is_merge, is_branch_tip) { + (true, _, _) => ConnType::CommitStash, + (_, true, _) => ConnType::CommitMerge, + (_, _, true) => ConnType::CommitBranch, + _ => ConnType::CommitNormal, + }; + + lanes[lane_idx] = Some((conn_type, lane_idx % 16)); + } else { + let is_dotted = head_id + .and_then(|h| self.oids.get(h)) + .is_some_and(|ha| { + chunk.parent_a == Some(ha) + || chunk.parent_b == Some(ha) + }) && lane_idx == 0; + + let is_orphan = chunk.parent_a.is_none() + && chunk.parent_b.is_none(); + + if is_orphan { + continue; + } + + let conn = if is_dotted { + ConnType::VerticalDotted + } else { + ConnType::Vertical + }; + + lanes[lane_idx] = Some((conn, lane_idx % 16)); + } + } + } + + fn draw_bridge( + lanes: &mut [Option<(ConnType, usize)>], + from: usize, + to: usize, + color_lane: usize, + mid: ConnType, + start: ConnType, + end: ConnType, + ) { + for lane in lanes.iter_mut().take(to).skip(from + 1) { + *lane = Some((mid, color_lane % 16)); + } + + if to > color_lane { + lanes[to] = Some((start, color_lane % 16)); + } else if from < color_lane { + lanes[from] = Some((end, color_lane % 16)); + } + } +} diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 98cc7238f9..ba6eea2525 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -8,7 +8,6 @@ It also provides synchronous Git operations. It wraps libraries like git2 and gix. */ -#![forbid(missing_docs)] #![deny( mismatched_lifetime_syntaxes, unused_imports, @@ -52,6 +51,7 @@ mod diff; mod error; mod fetch_job; mod filter_commits; +pub mod graph; mod progress; mod pull; mod push; diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 774d5140ef..607a2babf1 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -1,14 +1,16 @@ use crate::{ error::Result, + graph::{GraphRow, GraphWalker}, sync::{ - gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, + get_commits_info, gix_repo, repo, CommitId, LogWalker, + LogWalkerWithoutFilter, RepoPath, SharedCommitFilterFn, }, AsyncGitNotification, Error, }; use crossbeam_channel::Sender; use scopetime::scope_time; use std::{ + collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -45,6 +47,7 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, + graph_walker: Arc>, } static LIMIT_COUNT: usize = 3000; @@ -70,9 +73,33 @@ impl AsyncLog { background: Arc::new(AtomicBool::new(false)), filter, partial_extract: AtomicBool::new(false), + graph_walker: Arc::new(Mutex::new(GraphWalker::new())), } } + pub fn get_graph_rows( + &self, + commit_slice: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Option> { + let walker_guard = self.graph_walker.lock().ok()?; + let needed_end = global_start + commit_slice.len(); + if walker_guard.buffer.deltas.len() < needed_end { + return None; + } + + Some(walker_guard.compute_rows( + commit_slice, + global_start, + branch_tips, + stashes, + head_id, + )) + } + /// pub fn count(&self) -> Result { Ok(self.current.lock()?.commits.len()) @@ -165,6 +192,7 @@ impl AsyncLog { let sender = self.sender.clone(); let arc_pending = Arc::clone(&self.pending); let arc_background = Arc::clone(&self.background); + let arc_graph_walker = Arc::clone(&self.graph_walker); let filter = self.filter.clone(); let repo_path = self.repo.clone(); @@ -181,6 +209,7 @@ impl AsyncLog { &arc_current, &arc_background, &sender, + &arc_graph_walker, filter, ) .expect("failed to fetch"); @@ -198,6 +227,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -207,6 +237,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_graph_walker, ) }, |filter| { @@ -215,6 +246,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_graph_walker, filter, ) }, @@ -226,6 +258,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, filter: SharedCommitFilterFn, ) -> Result<()> { let start_time = Instant::now(); @@ -241,6 +274,18 @@ impl AsyncLog { entries.clear(); let read = walker.read(&mut entries)?; + { + let infos = get_commits_info( + repo_path, + &entries[0..read], + 1000, + )?; + let mut gw = arc_graph_walker.lock()?; + for info in &infos { + gw.process(info); + } + } + let mut current = arc_current.lock()?; current.commits.extend(entries.iter()); current.duration = start_time.elapsed(); @@ -270,6 +315,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, ) -> Result<()> { let start_time = Instant::now(); @@ -284,6 +330,18 @@ impl AsyncLog { entries.clear(); let read = walker.read(&mut entries)?; + { + let infos = get_commits_info( + repo_path, + &entries[0..read], + 1000, + )?; + let mut gw = arc_graph_walker.lock()?; + for info in &infos { + gw.process(info); + } + } + let mut current = arc_current.lock()?; current.commits.extend(entries.iter()); current.duration = start_time.elapsed(); @@ -312,6 +370,7 @@ impl AsyncLog { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; self.partial_extract.store(false, Ordering::Relaxed); + *self.graph_walker.lock()? = GraphWalker::new(); Ok(()) } @@ -332,6 +391,7 @@ mod tests { use serial_test::serial; use tempfile::TempDir; + use crate::graph::GraphWalker; use crate::sync::tests::{debug_cmd_print, repo_init}; use crate::sync::RepoPath; use crate::AsyncLog; @@ -359,12 +419,15 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_graph_walker = + Arc::new(Mutex::new(GraphWalker::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, &arc_current, &arc_background, &tx_git, + &arc_graph_walker, ); assert_eq!(result.unwrap(), ()); @@ -387,6 +450,8 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_graph_walker = + Arc::new(Mutex::new(GraphWalker::new())); std::env::set_var("GIT_DIR", git_dir); @@ -396,6 +461,7 @@ mod tests { &arc_current, &arc_background, &tx_git, + &arc_graph_walker, ); std::env::remove_var("GIT_DIR"); diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 89d847c8c2..44c9a3c506 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -10,6 +10,7 @@ use crate::{ }; use git2::{Commit, Error, Oid}; use scopetime::scope_time; +use smallvec::SmallVec; use unicode_truncate::UnicodeTruncateStr; /// identifies a single commit @@ -122,6 +123,8 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// + pub parents: SmallVec<[CommitId; 2]>, } /// @@ -155,6 +158,11 @@ pub fn get_commits_info( author, time: c.time().seconds(), id: CommitId(c.id()), + parents: c + .parents() + .take(2) + .map(|p| CommitId::new(p.id())) + .collect(), } }) .collect::>(); @@ -189,6 +197,13 @@ pub fn get_commit_info( author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents: commit_ref + .parents + .iter() + .take(2) + .map(|p| CommitId::from_str_unchecked(&p.to_string())) + .collect::, _>>()? + .into(), }) } diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index fa21185dc7..357e7a9afc 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,3 +1,10 @@ +use super::utils::graphrow::{ + SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, + SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, + SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, + SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, + SYM_VERTICAL, SYM_VERTICAL_DOTTED, +}; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, @@ -13,9 +20,12 @@ use crate::{ ui::{calc_scroll_top, draw_scrollbar, Orientation}, }; use anyhow::Result; -use asyncgit::sync::{ - self, checkout_commit, BranchDetails, BranchInfo, CommitId, - RepoPathRef, Tags, +use asyncgit::{ + graph::{ConnType, GraphRow}, + sync::{ + self, checkout_commit, BranchDetails, BranchInfo, CommitId, + RepoPathRef, Tags, + }, }; use chrono::{DateTime, Local}; use crossterm::event::Event; @@ -23,14 +33,14 @@ use indexmap::IndexSet; use itertools::Itertools; use ratatui::{ layout::{Alignment, Rect}, - style::Style, + style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; +use std::borrow::Cow; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, - time::Instant, + cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; const ELEMENTS_PER_LINE: usize = 9; @@ -58,6 +68,8 @@ pub struct CommitList { theme: SharedTheme, queue: Queue, key_config: SharedKeyConfig, + show_graph: bool, + graph_col_width: Cell, } impl CommitList { @@ -81,9 +93,23 @@ impl CommitList { queue: env.queue.clone(), key_config: env.key_config.clone(), title: title.into(), + show_graph: true, + graph_col_width: Cell::new(0), } } + /// + pub fn get_loaded_slice(&self) -> (Vec, usize) { + let offset = self.items.index_offset(); + let ids = self.items.iter().map(|e| e.id).collect(); + (ids, offset) + } + + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + self.items.set_graph_rows(rows); + } + /// pub const fn tags(&self) -> Option<&Tags> { self.tags.as_ref() @@ -175,6 +201,20 @@ impl CommitList { } } + /// + pub const fn local_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.local_branches + } + + /// + pub const fn remote_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.remote_branches + } + /// pub fn set_local_branches( &mut self, @@ -439,6 +479,98 @@ impl CommitList { } } + fn build_graph_spans<'a>( + &self, + row: &'a GraphRow, + graph_col_width: usize, + empty_lanes: &std::collections::HashSet, + ) -> Vec> { + let mut spans = Vec::new(); + + let graph_color = + self.theme.commit_hash(false).fg.unwrap_or(Color::Reset); + + for (lane_index, conn) in row.lanes.iter().enumerate() { + if empty_lanes.contains(&lane_index) { + continue; + } + let (sym, color) = match conn { + None => (SYM_SPACE, Color::Reset), + Some((ConnType::Vertical, _)) => { + (SYM_VERTICAL, graph_color) + } + Some((ConnType::VerticalDotted, _)) => { + (SYM_VERTICAL_DOTTED, graph_color) + } + Some((ConnType::CommitNormal, _)) => { + (SYM_COMMIT, graph_color) + } + Some((ConnType::CommitBranch, _)) => { + (SYM_COMMIT_BRANCH, graph_color) + } + Some((ConnType::CommitMerge, _)) => { + (SYM_COMMIT_MERGE, graph_color) + } + Some((ConnType::CommitStash, _)) => { + (SYM_COMMIT_STASH, graph_color) + } + Some((ConnType::CommitUncommitted, _)) => { + (SYM_COMMIT_UNCOMMITTED, graph_color) + } + Some((ConnType::MergeBridgeStart, _)) => { + (SYM_MERGE_BRIDGE_START, graph_color) + } + Some((ConnType::MergeBridgeMid, _)) => { + (SYM_MERGE_BRIDGE_MID, graph_color) + } + Some((ConnType::MergeBridgeEnd, _)) => { + (SYM_MERGE_BRIDGE_END, graph_color) + } + Some((ConnType::BranchDown, _)) => { + (SYM_BRANCH_DOWN, graph_color) + } + Some((ConnType::BranchUp, _)) => { + (SYM_BRANCH_UP, graph_color) + } + Some((ConnType::BranchUpRight, _)) => { + (SYM_BRANCH_UP_RIGHT, graph_color) + } + }; + spans.push(Span::styled(sym, Style::default().fg(color))); + + // Spacer + let mut is_bridge_lane = false; + + if let Some((from, to)) = row.merge_bridge { + if lane_index >= from && lane_index < to { + is_bridge_lane = true; + } + } + + for &(from, to) in &row.branches { + if lane_index >= from && lane_index < to { + is_bridge_lane = true; + } + } + + if is_bridge_lane { + spans.push(Span::styled( + SYM_HORIZONTAL, + Style::default().fg(graph_color), + )); + continue; + } + spans.push(Span::raw(SYM_SPACE)); + } + + let current_width = spans.len(); + for _ in current_width..graph_col_width { + spans.push(Span::raw(SYM_SPACE)); + } + + spans + } + #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -451,17 +583,21 @@ impl CommitList { width: usize, now: DateTime, marked: Option, + empty_lanes: &std::collections::HashSet, ) -> Line<'a> { let mut txt: Vec = Vec::with_capacity( ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, ); + if self.show_graph { + self.add_graph_spans(e, &mut txt, empty_lanes); + } + let normal = !self.items.highlighting() || (self.items.highlighting() && e.highlighted); - let splitter_txt = Cow::from(symbol::EMPTY_SPACE); let splitter = Span::styled( - splitter_txt, + Cow::from(symbol::EMPTY_SPACE), if normal { theme.text(true, selected) } else { @@ -482,40 +618,80 @@ impl CommitList { txt.push(splitter.clone()); } - let style_hash = if normal { - theme.commit_hash(selected) - } else { - theme.commit_unhighlighted() - }; - let style_time = if normal { - theme.commit_time(selected) - } else { - theme.commit_unhighlighted() - }; - let style_author = if normal { - theme.commit_author(selected) - } else { - theme.commit_unhighlighted() - }; - let style_tags = if normal { - theme.tags(selected) - } else { - theme.commit_unhighlighted() - }; - let style_branches = if normal { - theme.branch(selected, true) + Self::add_entry_details( + e, + &mut txt, + selected, + normal, + theme, + width, + now, + tags, + local_branches, + remote_branches, + &splitter, + ); + + Line::from(txt) + } + + fn add_graph_spans<'a>( + &self, + e: &'a LogEntry, + txt: &mut Vec>, + empty_lanes: &std::collections::HashSet, + ) { + if let Some(ref row) = e.graph { + txt.extend(self.build_graph_spans( + row, + self.graph_col_width.get(), + empty_lanes, + )); } else { - theme.commit_unhighlighted() - }; - let style_msg = if normal { - theme.text(true, selected) + txt.push(Span::raw( + " ".repeat(self.graph_col_width.get()), + )); + } + txt.push(Span::raw(" ")); + } + + #[allow(clippy::too_many_arguments)] + fn add_entry_details<'a>( + e: &'a LogEntry, + txt: &mut Vec>, + selected: bool, + normal: bool, + theme: &Theme, + width: usize, + now: DateTime, + tags: Option, + local_branches: Option, + remote_branches: Option, + splitter: &Span<'a>, + ) { + let ( + style_hash, + style_time, + style_author, + style_tags, + style_branches, + style_msg, + ) = if normal { + ( + theme.commit_hash(selected), + theme.commit_time(selected), + theme.commit_author(selected), + theme.tags(selected), + theme.branch(selected, true), + theme.text(true, selected), + ) } else { - theme.commit_unhighlighted() + let s = theme.commit_unhighlighted(); + (s, s, s, s, s, s) }; // commit hash txt.push(Span::styled(Cow::from(&*e.hash_short), style_hash)); - txt.push(splitter.clone()); // commit timestamp @@ -523,7 +699,6 @@ impl CommitList { Cow::from(e.time_to_string(now)), style_time, )); - txt.push(splitter.clone()); let author_width = @@ -532,7 +707,6 @@ impl CommitList { // commit author txt.push(Span::styled(author, style_author)); - txt.push(splitter.clone()); // commit tags @@ -550,7 +724,7 @@ impl CommitList { txt.push(Span::styled(remote_branches, style_branches)); } - txt.push(splitter); + txt.push(splitter.clone()); let message_width = width.saturating_sub( txt.iter().map(|span| span.content.len()).sum(), @@ -561,8 +735,6 @@ impl CommitList { format!("{:message_width$}", &e.msg), style_msg, )); - - Line::from(txt) } fn get_text(&self, height: usize, width: usize) -> Vec> { @@ -570,9 +742,60 @@ impl CommitList { let mut txt: Vec = Vec::with_capacity(height); - let now = Local::now(); + let mut empty_lanes = std::collections::HashSet::new(); + + if self.show_graph { + let mut max_lane_in_view = 0; + for e in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + { + if let Some(row) = &e.graph { + max_lane_in_view = + max_lane_in_view.max(row.lanes.len()); + } + } + + // Assume all lanes up to max_lane_in_view are empty + empty_lanes.extend(0..max_lane_in_view); + + // Remove lanes that have content + for e in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + { + if let Some(row) = &e.graph { + for (lane_idx, conn) in + row.lanes.iter().enumerate() + { + if !matches!( + conn, + None | Some(( + ConnType::MergeBridgeMid, + _ + )) + ) { + empty_lanes.remove(&lane_idx); + } + } + } + } + + let width = ((max_lane_in_view + .saturating_sub(empty_lanes.len())) + * 2) + .max(2); + self.graph_col_width.set(width); + } else { + self.graph_col_width.set(0); + } let any_marked = !self.marked.is_empty(); + let now = Local::now(); for (idx, e) in self .items @@ -616,6 +839,7 @@ impl CommitList { width, now, marked, + &empty_lanes, )); } @@ -863,6 +1087,12 @@ impl Component for CommitList { ) { self.checkout(); true + } else if key_match( + k, + self.key_config.keys.log_toggle_graph, + ) { + self.show_graph = !self.show_graph; + true } else { false }; @@ -890,6 +1120,11 @@ impl Component for CommitList { true, true, )); + out.push(CommandInfo::new( + strings::commands::log_toggle_graph(&self.key_config), + true, + true, + )); CommandBlocking::PassingOn } } @@ -922,6 +1157,8 @@ mod tests { std::path::PathBuf::default(), )), queue: Queue::default(), + show_graph: true, + graph_col_width: Cell::new(0), } } } @@ -956,6 +1193,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: Vec::default().into(), }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 @@ -1069,4 +1307,24 @@ mod tests { ))) ); } + + #[test] + fn test_build_graph_spans() { + let cl = CommitList::default(); + let row = GraphRow { + lane_count: 1, + commit_lane: 0, + is_merge: false, + is_branch_tip: false, + is_stash: false, + lanes: vec![Some((ConnType::CommitNormal, 0))], + merge_bridge: None, + branches: vec![], + }; + let empty_lanes = std::collections::HashSet::new(); + let spans = cl.build_graph_spans(&row, 1, &empty_lanes); + + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); + } } diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs new file mode 100644 index 0000000000..db44162a1b --- /dev/null +++ b/src/components/utils/graphrow.rs @@ -0,0 +1,15 @@ +pub const SYM_COMMIT: &str = "◆"; +pub const SYM_COMMIT_BRANCH: &str = "◈"; +pub const SYM_COMMIT_MERGE: &str = "◇"; +pub const SYM_COMMIT_STASH: &str = "⟡"; +pub const SYM_COMMIT_UNCOMMITTED: &str = "◻"; +pub const SYM_VERTICAL: &str = "┃"; +pub const SYM_VERTICAL_DOTTED: &str = "╏"; +pub const SYM_HORIZONTAL: &str = "━"; +pub const SYM_MERGE_BRIDGE_START: &str = "┓"; +pub const SYM_MERGE_BRIDGE_MID: &str = "━"; +pub const SYM_MERGE_BRIDGE_END: &str = "┏"; +pub const SYM_BRANCH_UP: &str = "┛"; +pub const SYM_BRANCH_DOWN: &str = "┓"; +pub const SYM_BRANCH_UP_RIGHT: &str = "┗"; +pub const SYM_SPACE: &str = " "; diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 9e0706226e..1ff46079a6 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,4 +1,7 @@ -use asyncgit::sync::{CommitId, CommitInfo}; +use asyncgit::{ + graph::GraphRow, + sync::{CommitId, CommitInfo}, +}; use chrono::{DateTime, Duration, Local, Utc}; use indexmap::IndexSet; use std::{rc::Rc, slice::Iter}; @@ -20,6 +23,7 @@ pub struct LogEntry { pub hash_short: BoxStr, pub id: CommitId, pub highlighted: bool, + pub graph: Option, } impl From for LogEntry { @@ -54,6 +58,7 @@ impl From for LogEntry { hash_short, id: c.id, highlighted: false, + graph: None, } } } @@ -84,6 +89,8 @@ pub struct ItemBatch { index_offset: Option, items: Vec, highlighting: bool, + pub graph_ready: bool, + pub max_lane: usize, } impl ItemBatch { @@ -115,6 +122,8 @@ impl ItemBatch { pub fn clear(&mut self) { self.items.clear(); self.index_offset = None; + self.graph_ready = false; + self.max_lane = 0; } /// insert new batch of items @@ -143,6 +152,17 @@ impl ItemBatch { } } + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + let mut max = 0; + for (entry, row) in self.items.iter_mut().zip(rows) { + max = max.max(row.lane_count); + entry.graph = Some(row); + } + self.max_lane = max; + self.graph_ready = true; + } + /// returns `true` if we should fetch updated list of items pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { let want_min = diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 29485be1e4..746b2787bf 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -4,6 +4,7 @@ use unicode_width::UnicodeWidthStr; #[cfg(feature = "ghemoji")] pub mod emoji; pub mod filetree; +pub mod graphrow; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..6d75fae73c 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -129,6 +129,7 @@ pub struct KeysList { pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, pub goto_line: GituiKeyEvent, + pub log_toggle_graph: GituiKeyEvent, } #[rustfmt::skip] @@ -227,6 +228,7 @@ impl Default for KeysList { commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), + log_toggle_graph: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), } } } diff --git a/src/strings.rs b/src/strings.rs index f66a9e93f5..9ce737325a 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1601,6 +1601,19 @@ pub mod commands { ) } + pub fn log_toggle_graph( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Graph [{}]", + key_config.get_hint(key_config.keys.log_toggle_graph), + ), + "toggle commit graph", + CMD_GROUP_LOG, + ) + } + pub fn reset_commit(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 9200c93f00..23687c5493 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -33,6 +33,7 @@ use ratatui::{ Frame, }; use std::{ + collections::HashSet, rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, @@ -134,6 +135,37 @@ impl Revlog { self.list .refresh_extend_data(self.git_log.extract_items()?); + let (slice, global_start) = self.list.get_loaded_slice(); + if !slice.is_empty() { + let mut branch_tips = HashSet::new(); + let mut head_id = None; + + for (id, branches) in self.list.local_branches() { + branch_tips.insert(*id); + if head_id.is_none() + && branches.iter().any(|b| { + b.local_details() + .is_some_and(|d| d.is_head) + }) { + head_id = Some(*id); + } + } + branch_tips + .extend(self.list.remote_branches().keys()); + + let stashes = HashSet::new(); + + if let Some(rows) = self.git_log.get_graph_rows( + &slice, + global_start, + &branch_tips, + &stashes, + head_id.as_ref(), + ) { + self.list.set_graph_rows(rows); + } + } + self.git_tags.request(Duration::from_secs(3), false)?; if self.commit_details.is_visible() {