diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..4a09c38431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) * automatically convert spaces to dashes when creating or renaming a branch [[@pbouillon]](https//pbouillon.github.io)] ([#2916](https://github.com/gitui-org/gitui/pull/2916)) * support rewording non-HEAD commits when `commit.gpgsign` is enabled (gpg format only) [[@guerinoni](https://github.com/guerinoni)] ([#2959](https://github.com/gitui-org/gitui/pull/2959)) +* always preserve topology order before sorting commits by date ### Fixes * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) diff --git a/Cargo.lock b/Cargo.lock index a699e228d1..c634a7b47d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,17 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -347,7 +358,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -1130,7 +1143,7 @@ dependencies = [ [[package]] name = "gitui" -version = "0.28.1" +version = "0.28.1-alpha" dependencies = [ "anyhow", "asyncgit", @@ -1151,6 +1164,7 @@ dependencies = [ "fuzzy-matcher", "gh-emoji", "git2-testing", + "gleisbau", "indexmap", "insta", "itertools", @@ -1528,7 +1542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1966,6 +1980,27 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "gleisbau" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac933c4eb1ee130b83108cd6b750f2092e96fd0c65883c40451933af5975c48e" +dependencies = [ + "atty", + "chrono", + "crossterm 0.29.0", + "git2", + "itertools", + "lazy_static", + "log", + "regex", + "serde", + "serde_derive", + "textwrap", + "toml", + "yansi", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2028,6 +2063,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -3376,6 +3420,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serial_test" version = "3.3.1" @@ -3754,6 +3807,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3862,6 +3924,45 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "two-face" version = "0.4.5" @@ -4481,6 +4582,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index 585f94a147..a741173c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitui" -version = "0.28.1" +version = "0.28.1-alpha" authors = ["extrawurst "] description = "blazing fast terminal-ui for git" edition = "2021" @@ -41,6 +41,7 @@ easy-cast = "0.5" filetreelist = { path = "./filetreelist", version = ">=0.6" } fuzzy-matcher = "0.3" gh-emoji = { version = "1.0", optional = true } +gleisbau = { version = "0.7.5", default-features = false } indexmap = "2" itertools = "0.14" log = "0.4" diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index e89e4a56f8..c764a0b934 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -73,6 +73,10 @@ pub enum GixError { #[error("gix::status::tree_index::Error error: {0}")] StatusTreeIndex(#[from] Box), + /// + #[error("gix::traverse::commit::topo error: {0}")] + Topo(#[from] gix::traverse::commit::topo::Error), + /// #[error("gix::worktree::open_index::Error error: {0}")] WorktreeOpenIndex(#[from] Box), @@ -333,6 +337,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: gix::traverse::commit::topo::Error) -> Self { + Self::Gix(GixError::from(error)) + } +} + impl From for GixError { fn from(error: gix::worktree::open_index::Error) -> Self { Self::WorktreeOpenIndex(Box::new(error)) diff --git a/asyncgit/src/sync/branch/merge_commit.rs b/asyncgit/src/sync/branch/merge_commit.rs index ec1ea498bb..4733d37023 100644 --- a/asyncgit/src/sync/branch/merge_commit.rs +++ b/asyncgit/src/sync/branch/merge_commit.rs @@ -101,7 +101,7 @@ mod test { branch_compare_upstream, remotes::{fetch, push::push_branch}, tests::{ - debug_cmd_print, get_commit_ids, repo_clone, + debug_cmd_print, get_commit_ids, repo_clone, repo_init, repo_init_bare, write_commit_file, write_commit_file_at, }, RepoState, @@ -278,4 +278,111 @@ mod test { let commits = get_commit_ids(&clone1, 10); assert_eq!(commits.len(), 1); } + + /// Verify that the walker preserves topology order. This means that + /// no parent is visited before any of its children. + #[test] + fn test_topology_order() { + /* Test case + a diamon shaped graph, where the common ancestor is younger than + one of its children. + + GP--P1--M + \ / + --P2 + + */ + + let (_repo_dir, repo) = repo_init().unwrap(); + + // 1. Grandparent (GP) - Newest + let gp = write_commit_file_at( + &repo, + "gp.txt", + "gp", + "gp", + Time::new(1000, 0), + ); + + // 2. Parent 1 (P1) - Older + let p1 = write_commit_file_at( + &repo, + "p1.txt", + "p1", + "p1", + Time::new(500, 0), + ); + + // 3. Parent 2 (P2) - Older (diverging from GP) + // Reset HEAD to GP so P2 becomes a child of GP + repo.reset( + repo.find_object(gp.into(), None) + .unwrap() + .as_commit() + .unwrap() + .as_object(), + git2::ResetType::Hard, + None, + ) + .unwrap(); + let p2 = write_commit_file_at( + &repo, + "p2.txt", + "p2", + "p2", + Time::new(400, 0), + ); + + // 4. Merge commit (M) - The starting point of our walk + // The heap now contains [p1, p2]. + // If we pop p1, we add gp. The heap is [gp, p2]. + // Because gp(1000) > p2(400), the walker returns gp before p2. + // This is a violation: p2 is a child of gp and must be visited first. + let p1_commit = repo.find_commit(p1.into()).unwrap(); + let p2_commit = repo.find_commit(p2.into()).unwrap(); + let tree = repo + .find_tree(repo.index().unwrap().write_tree().unwrap()) + .unwrap(); + let sig = repo.signature().unwrap(); + let m = repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Merge p1 into p2", + &tree, + &[&p2_commit, &p1_commit], + ) + .unwrap(); + let m = CommitId::new(m); + + // Expected Topological Order: [M, P1, P2, GP] or [M, P2, P1, GP] + // Actual Defective Order: [M, P1, GP, P2] + // (GP jumps ahead of P2 because 1000 > 400) + + let commits = get_commit_ids(&repo, 14); + for (i, id) in commits.iter().enumerate() { + println!("DEBUG: commits[{}] = {:?}", i, id); + // Print the message of the commit to identify it + let repo_path = &repo.path().to_path_buf().into(); + let details = + crate::sync::get_commit_details(repo_path, *id) + .unwrap(); + println!( + "DEBUG: Message: {:?}", + details.message.map(|m| m.combine()) + ); + } + println!("DEBUG: Expected M is {:?}", m); + println!("DEBUG: P1 is {:?}", &p1); + println!("DEBUG: P2 is {:?}", &p2); + println!("DEBUG: GP is {:?}", &gp); + assert_eq!(commits[0], m); + assert!(commits.contains(&p1)); + assert!(commits.contains(&p2)); + assert_eq!( + commits[3], gp, + "Violation: Grandparent must be the last commit" + ); + } } diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 772b73a040..a7be87de8f 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -111,7 +111,8 @@ impl From for gix::ObjectId { } } -/// +/// The data gitui stores in memory about a commit. Large repositories +/// may hold millions and have a major impact on gitui memory usage. #[derive(Debug, Clone)] pub struct CommitInfo { /// @@ -122,9 +123,11 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// List of parent ids + pub parents: Vec, } -/// +/// Load information about the requested commits from repository pub fn get_commits_info( repo_path: &RepoPath, ids: &[CommitId], @@ -151,11 +154,13 @@ pub fn get_commits_info( || String::from(""), String::from, ); + let parents = c.parent_ids().map(CommitId).collect(); CommitInfo { message, author, time: c.time().seconds(), id: CommitId(c.id()), + parents, } }) .collect::>(); @@ -163,7 +168,7 @@ pub fn get_commits_info( Ok(res) } -/// +/// Load information about a single commit from repository pub fn get_commit_info( repo_path: &RepoPath, commit_id: &CommitId, @@ -185,11 +190,15 @@ pub fn get_commit_info( |signature| signature.name, ); + let parents = + commit.parent_ids().map(|id| id.detach().into()).collect(); + Ok(CommitInfo { message, author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents, }) } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 81a6fd321e..b524998bf7 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -1,66 +1,52 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; -use git2::{Commit, Oid, Repository}; -use gix::revision::Walk; -use std::{ - cmp::Ordering, - collections::{BinaryHeap, HashSet}, -}; +use git2::{Repository, Revwalk, Sort}; +use gix::traverse::commit::topo; +use gix::traverse::commit::Topo; -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()) - } -} - -/// +/// Visit commits in topological order, and date order where possible. +/// If a filter is provided, only commits that pass the filter are returned. pub struct LogWalker<'a> { - commits: BinaryHeap>, - visited: HashSet, + /// Revision walk engine + walk: Revwalk<'a>, + /// Total number of commits that have been visited + visited_count: usize, + /// Upper limit on buffer size limit: usize, + /// The source of commits repo: &'a Repository, + /// Filter on which commits will be returned when reading filter: Option, } impl<'a> LogWalker<'a> { - /// + /// Create a new log walker + /// with an upper limit on number of commits visited in one batch. pub fn new(repo: &'a Repository, limit: usize) -> Result { - let c = repo.head()?.peel_to_commit()?; + let mut walk = repo.revwalk()?; + // TOPOLOGICAL + TIME guarantees parents come after children, + // and ties/independent branches are ordered by timestamp (--date-order). + walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; - let mut commits = BinaryHeap::with_capacity(10); - commits.push(TimeOrderedCommit(c)); + // Push all references (heads, tags, remotes, etc.) into the revision walker. + // This corresponds to running "git log --all" + walk.push_glob("*")?; Ok(Self { - commits, + walk, + visited_count: 0, limit, - visited: HashSet::with_capacity(1000), repo, filter: None, }) } - /// - pub fn visited(&self) -> usize { - self.visited.len() + /// Number of visited commits + pub const fn visited(&self) -> usize { + self.visited_count } - /// + /// Add a filter to use when reading commits #[must_use] pub fn filter( self, @@ -69,16 +55,14 @@ impl<'a> LogWalker<'a> { Self { filter, ..self } } - /// + /// Get a batch of commits pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; - while let Some(c) = self.commits.pop() { - for p in c.0.parents() { - self.visit(p); - } + for oid_result in self.walk.by_ref() { + let oid = oid_result?; + let id: CommitId = oid.into(); - let id: CommitId = c.0.id().into(); let commit_should_be_included = if let Some(ref filter) = self.filter { filter(self.repo, &id)? @@ -90,6 +74,7 @@ impl<'a> LogWalker<'a> { out.push(id); } + self.visited_count += 1; count += 1; if count == self.limit { break; @@ -98,13 +83,6 @@ impl<'a> LogWalker<'a> { Ok(count) } - - // - fn visit(&mut self, c: Commit<'a>) { - if self.visited.insert(c.id()) { - self.commits.push(TimeOrderedCommit(c)); - } - } } /// This is separate from `LogWalker` because filtering currently (June 2024) works through @@ -118,7 +96,7 @@ impl<'a> LogWalker<'a> { /// A more long-term option is to refactor filtering to work with a `gix::Repository` and to remove /// `LogWalker` once this is done, but this is a larger effort. pub struct LogWalkerWithoutFilter<'a> { - walk: Walk<'a>, + walk: Topo<&'a gix::Repository, fn(&gix::hash::oid) -> bool>, limit: usize, visited: usize, } @@ -133,16 +111,47 @@ impl<'a> LogWalkerWithoutFilter<'a> { // reason this is 2^14, so benchmarking might reveal that there’s better values. repo.object_cache_size_if_unset(2_usize.pow(14)); - let commit = repo.head()?.peel_to_commit()?; - - let tips = [commit.id]; - - let platform = repo - .rev_walk(tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(gix::traverse::commit::simple::CommitTimeOrder::NewestFirst)) - .use_commit_graph(false); - - let walk = platform.all()?; + // Walk every local branch + let mut tips = Vec::new(); + for ref_result in repo.references()?.local_branches()? { + let mut reference = match ref_result { + Ok(reference) => reference, + Err(err) => { + log::warn!("failed to read local branch reference: {err}"); + continue; + } + }; + + match reference.peel_to_commit() { + Ok(commit) => tips.push(commit.id), + Err(err) => { + log::warn!("failed to resolve local branch {} to a commit: {}", + reference.name().as_bstr(), + err, + ); + } + } + } + // .. and HEAD, in case it is detached + match repo.head()?.try_peel_to_id() { + Ok(Some(id)) => tips.push(id.detach()), + Ok(None) => {} + Err(err) => { + log::warn!("failed to resolve HEAD: {err}"); + } + } + // Avoid bug in gitoxide that triggers when adding two identical + // starting points for the walk. + // It is valid for multiple refs to point to the same commit. + tips.sort_unstable(); + tips.dedup(); + + let walk = topo::Builder::new(&*repo) + // Show no parents before all of its children are shown, + // but otherwise show commits in the commit timestamp order. + .sorting(topo::Sorting::DateOrder) + .with_tips(tips) + .build()?; Ok(Self { walk, @@ -160,7 +169,8 @@ impl<'a> LogWalkerWithoutFilter<'a> { pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; - while let Some(Ok(info)) = self.walk.next() { + while let Some(info) = self.walk.next() { + let info = info?; out.push(info.id.into()); count += 1; diff --git a/docs/design/log-graph.md b/docs/design/log-graph.md new file mode 100644 index 0000000000..2b7e120c24 --- /dev/null +++ b/docs/design/log-graph.md @@ -0,0 +1,104 @@ +# Title + +Visualization of branch structure + +Author: [Peer Sommerlund](mailto:peer.sommerlund@gmail.com) + +**Summary:** +Parent-child relations between commits form a graph that is important +for many operations like push, rebase, etc. Gitui can list commits, but +does not show how they are related. This design shows how to integrate +the crate gleisbau so the log tab shows the branch graph. + +## State of the Feature as of `gitui 0.26.3` + +Gitui shows all commits in a repository without regard for their parent +commit relations. This may cause commits on multiple branches to +be shown interleaved in the log tab. + +## Prior work + +`git log --graph` is well known, less known is gleisbau which has a +cleaner visual approach. + +## Goals and non-goals + +The project aims to implement a graphical representation of commits. +As as secondary goal, it adds the branch sorting feature of gleisbau. + +It does not implement a language for filtering commits dependent on +their branch. + +## Overview + +The project adds the cargo gleisbau and provides an alternative view +for the CommitList component, which is used in the revlog tab. + +### Detailed Design + +* A lazy cache of rendered text + gleisbau is used to render lines surrounding the cursor/selection + on screen. Doing this for a large repository may use several GB of + memory and be very slow. To get fast response and low memory usage + only a fixed number of commits is rendered. As the user moves around + a new set will be rendered. For very fast scrolling, the old + rendering will be used, and replaced async when gleisbau + catches up. + +* A new system for tracking location + In order to handle multi-line commits larger than the screen, we + track single lines. This is done with a new `struct DocLine` which + points at a commit, and optionally a line in the commit rendering. + + TODO: Consider if this should be replaced by the old system. + I need to show single lines, but what benefit does the user get + from being able to address single lines? It will make sense if a commit + has more lines than can be shown, as a way to control scrolling. + +* gleisbau patches + The changes to make gleisbau work inside gitui will be contributed + upstream to gleisbau. When that happens, the local copy should be + removed. Another option is to defer merge of this PR until gleisbau + PR is merged. + +* asyncgit patches + There are a few features in asyncgit which were private. These have + been published. + +#### asyncgit patches + +gitui loads commits via asyncgit. The full lifecycle of commit information +is: + +- Init: main somehwere +- Load-Thread: Asyncgit sends AsyncGitNotification + which is handled by Revlog.update_git() +- UI-delivery: CommitList.fetch_commits read commits into ItemBatch + it calls asyncgit::sync::get_commits_info + It always grows CommitList.commits so it includes the selection and some. +- UI-rendering: CommitList.draw() draw the loaded data found in + The interesting part is that self.commits.len() is used to count + commit in the repository. That means that CommitList.commits eventually + will hold the full repository. + draw calls CommitList.get_text which renders the visible commits as text. + +## Alternatives considered + +- Show output from `git log --graph`. This would make it easy for users +to understand what is going on and how to change the format, an any +future features of git would be automatically included. The downside is +dependency on the git binary, and a slower UI. It might be difficult and +brittle to parse the output. + +- Implement branch visualization from scratch. Upside is full control +over memory and UI responsiveness. Downside is a larger effort needed. + +## Issues addressed (optional) + +- [#81]() + + +## Future Possibilities + + The section for things which could be added to it or deemed out of scope during + the discussion. diff --git a/src/app.rs b/src/app.rs index ddb157af3f..86fab3d2c5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -416,7 +416,7 @@ impl App { Ok(()) } - /// + /// Distribute async notification to all children pub fn update_async( &mut self, ev: AsyncNotification, diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e97754ecb4..8f680632e8 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,9 +1,15 @@ +/*! +The [`CommitList`] shows a list of commits. It is used by +the [revlog](crate::tabs::Revlog) tab +and the [stashlist](crate::tabs::StashList) tab. +*/ use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, components::{ utils::string_width_align, CommandBlocking, CommandInfo, - Component, DrawableComponent, EventState, ScrollType, + Component, DrawableComponent, EventState, GraphCache, + ScrollType, }, keys::{key_match, SharedKeyConfig}, queue::{InternalEvent, Queue}, @@ -29,7 +35,11 @@ use ratatui::{ Frame, }; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, + borrow::Cow, + cell::{Cell, RefCell}, + cmp, + collections::BTreeMap, + rc::Rc, time::Instant, }; @@ -38,13 +48,32 @@ const SLICE_SIZE: usize = 1200; /// pub struct CommitList { + // + // Information about the repository + // + + // ---- rustfmt, please preserve blank line above ---- + /// Location of repository repo: RepoPathRef, + + /// Sequence of commit id for commits loaded from git + commits: IndexSet, + + /// Commit information loaded from git + items: ItemBatch, + + /// Commit graph related to [items] + graph_cache: RefCell, + + // + // User interface + // + + // ---- rustfmt, please preserve blank line above ---- title: Box, selection: usize, highlighted_selection: Option, - items: ItemBatch, highlights: Option>>, - commits: IndexSet, /// The marked commits. /// `self.marked[].0` holds the commit index into `self.items.items` - used for ordering the list. /// `self.marked[].1` is the commit id of the marked commit. @@ -64,6 +93,9 @@ impl CommitList { /// pub fn new(env: &Environment, title: &str) -> Self { Self { + graph_cache: RefCell::new(GraphCache::new( + env.repo.clone(), + )), repo: env.repo.clone(), items: ItemBatch::default(), marked: Vec::with_capacity(2), @@ -205,7 +237,7 @@ impl CommitList { } } - /// + /// Clear self.items and fetch the commits indicated pub fn set_commits(&mut self, commits: IndexSet) { if commits != self.commits { self.items.clear(); @@ -214,7 +246,7 @@ impl CommitList { } } - /// + /// Extend self.commits with the provided commits. Fetch commit info pub fn refresh_extend_data(&mut self, commits: Vec) { let new_commits = !commits.is_empty(); self.commits.extend(commits); @@ -439,6 +471,7 @@ impl CommitList { } } + /// Format one commit for drawing #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -565,6 +598,7 @@ impl CommitList { Line::from(txt) } + /// Format commits visible inside the component, at the current scroll fn get_text(&self, height: usize, width: usize) -> Vec> { let selection = self.relative_selection(); @@ -572,17 +606,12 @@ impl CommitList { let now = Local::now(); - let any_marked = !self.marked.is_empty(); - - for (idx, e) in self - .items - .iter() - .skip(self.scroll_top.get()) - .take(height) - .enumerate() - { + fn tags_branches_marked( + this: &CommitList, + e: &LogEntry, + ) -> (Option, Option, Option) { let tags = - self.tags.as_ref().and_then(|t| t.get(&e.id)).map( + this.tags.as_ref().and_then(|t| t.get(&e.id)).map( |tags| { tags.iter() .map(|t| format!("<{}>", t.name)) @@ -591,7 +620,7 @@ impl CommitList { ); let local_branches = - self.local_branches.get(&e.id).map(|local_branch| { + this.local_branches.get(&e.id).map(|local_branch| { local_branch .iter() .map(|local_branch| { @@ -600,25 +629,60 @@ impl CommitList { .join(" ") }); + let any_marked = !this.marked.is_empty(); let marked = if any_marked { - self.is_marked(&e.id) + this.is_marked(&e.id) } else { None }; - txt.push(self.get_entry_to_add( - e, - idx + self.scroll_top.get() == selection, - tags, - local_branches, - self.remote_branches_string(e), - &self.theme, - width, - now, - marked, - )); + (tags, local_branches, marked) } + for (idx, e) in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + .enumerate() + { + // Loop over graph lines pr commit. There may be more than one + let commit_height = + self.graph_cache.borrow().layout_commit_height(idx); + for ofs in 0..commit_height { + let current_line = txt.len(); + let graph_column: Line = self + .graph_cache + .borrow() + .get_graph_line(current_line); + let text_column: Line = if ofs == 0 { + let (tags, local_branches, marked) = + tags_branches_marked(self, e); + self.get_entry_to_add( + e, + idx + self.scroll_top.get() == selection, + tags, + local_branches, + self.remote_branches_string(e), + &self.theme, + width, + now, + marked, + ) + } else { + Line::from("") + }; + txt.push( + graph_column + .into_iter() + .chain(text_column) + .collect::(), + ); + } + } + // Apply internal scroll to get selection into the visible area + txt.drain(0..self.graph_cache.borrow().row_scroll()); + txt } @@ -760,6 +824,7 @@ impl CommitList { ); if let Ok(commits) = commits { + self.graph_cache.borrow_mut().add_commits(&commits); self.items.set_items( want_min, commits, @@ -787,6 +852,15 @@ impl DrawableComponent for CommitList { selection, )); + // Update commit graph + { + let top = self.scroll_top.get(); + let visible_range = top..top + height_in_lines; + self.graph_cache + .borrow_mut() + .compute_layout(visible_range, self.selection); + } + let title = format!( "{} {}/{}", self.title, @@ -902,6 +976,11 @@ mod tests { impl Default for CommitList { fn default() -> Self { + let repo = RepoPathRef::new(sync::RepoPath::Path( + std::path::PathBuf::default(), + )); + let graph_cache = + RefCell::new(GraphCache::new(repo.clone())); Self { title: String::new().into_boxed_str(), selection: 0, @@ -909,6 +988,7 @@ mod tests { highlights: Option::None, tags: Option::None, items: ItemBatch::default(), + graph_cache, commits: IndexSet::default(), marked: Vec::default(), scroll_top: Cell::default(), @@ -918,9 +998,7 @@ mod tests { key_config: SharedKeyConfig::default(), scroll_state: (Instant::now(), 0.0), current_size: Cell::default(), - repo: RepoPathRef::new(sync::RepoPath::Path( - std::path::PathBuf::default(), - )), + repo, queue: Queue::default(), } } @@ -956,6 +1034,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: vec![], }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 diff --git a/src/components/mod.rs b/src/components/mod.rs index e4801a31b5..e67a49ae75 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -58,9 +58,9 @@ pub use revision_files::RevisionFilesComponent; pub use syntax_text::SyntaxTextComponent; pub use textinput::{InputType, TextInputComponent}; pub use utils::{ - filetree::FileTreeItemKind, logitems::ItemBatch, - scroll_vertical::VerticalScroll, string_width_align, - time_to_string, + filetree::FileTreeItemKind, graph_cache::GraphCache, + logitems::ItemBatch, scroll_vertical::VerticalScroll, + string_width_align, time_to_string, }; use crate::ui::style::Theme; diff --git a/src/components/utils/graph_cache.rs b/src/components/utils/graph_cache.rs new file mode 100644 index 0000000000..231e9ae291 --- /dev/null +++ b/src/components/utils/graph_cache.rs @@ -0,0 +1,210 @@ +/*! The `graph_cache` module implements an adaptor of the gleisbau library +to the internals of gitui. */ + +use std::cell::RefCell; +use std::ops::Range; +use std::rc::Rc; + +use gleisbau::backend::git2::Builder; +use gleisbau::backend::git2::TrackMap; +use gleisbau::layout::layout_track_range; +use gleisbau::layout::TrackLayout; +use gleisbau::print::format::CommitFormat; +use gleisbau::print::unicode::print_graph_terminal; +use gleisbau::print::unicode::GraphLines; +use gleisbau::settings::BranchOrder; +use gleisbau::settings::BranchSettings; +use gleisbau::settings::BranchSettingsDef; +use gleisbau::settings::Characters; +use gleisbau::settings::MergePatterns; +use gleisbau::settings::Settings; +use ratatui::text::Line; + +use asyncgit::sync::CommitInfo; +use asyncgit::sync::RepoPath; + +/* +/// Commit index type. Index into [CommitList.commits] +type Cinx = u32; + +/// Branch index type +type Binx = u32; +*/ + +/** Main struct used for branch graph. +*/ +pub struct GraphCache { + /// gleisbau configuration. This is used by the builder + /// as well as when updating layout + settings: Rc, + + /// Topology. + topo: Rc>, + + /// Geometry + geo: Option, + + /// Document + doc: Option, + + /// Internal line scroll adjustment so selection is visible. + /// This is necessary when some commits use more than one row. + row_scroll: usize, + + /// Builder used to incrementally fill topology data from repository. + /// Discarded when all commits has been processed. + builder: Option, +} + +fn extract_settings(_repo_path: RefCell) -> Settings { + // TODO read gleisbau config file in repository folder + // or remove _repo_path argument + + Settings { + // Reverse the order of commits + reverse_commit_order: false, + // Debug printing and drawing + debug: false, + // Compact text-based graph + compact: false, + // Colored text-based graph + colored: false, + // Include remote branches? + include_remote: false, + // Formatting for commits + format: CommitFormat::OneLine, // TODO eliminate - not needed + // Text wrapping options + wrapping: None, // TODO eliminate - not needed + // Characters to use for text-based graph + characters: Characters::round(), + // Branch column sorting algorithm + branch_order: BranchOrder::ShortestFirst(true), + // Settings for branches + branches: BranchSettings::from(BranchSettingsDef::none()) + .expect("Default settings should never fail"), + // Regex patterns for finding branch names in merge commit summaries + merge_patterns: MergePatterns::default(), + } +} + +impl GraphCache { + pub fn new(repo_path: RefCell) -> Self { + let settings = Rc::new(extract_settings(repo_path)); + let topo = Rc::new(RefCell::new(TrackMap::new())); + let builder = Some(Builder::new(topo.clone())); + Self { + settings, + topo, + geo: None, + doc: None, + row_scroll: 0, + builder, + } + } + + /// Expand the branch topology + pub fn add_commits(&mut self, commits: &Vec) { + let builder = self + .builder + .as_mut() + // Initial version expects builder to live forever + // Later version may create and discard as needed + .expect("Builder is never discarded"); + for c in commits { + let id = c.id.into(); + let message = c.message.clone(); + let parents: Vec<_> = + c.parents.iter().map(|&id| id.into()).collect(); + + builder.add_commit(id, message, parents); + } + } + + /// Layout a section of commits + pub fn compute_layout( + &mut self, + commit_range: Range, + selection: usize, + ) { + let first_commit = commit_range.start; + let height_in_lines = commit_range.len(); // Assume caller did top..top+height + let track_layout = layout_track_range( + &self.topo.borrow(), + commit_range, + &self.settings, + ) + .expect("Valid Trackmap and range"); + + // All commits are given 1 row for text + let text_height = vec![1; track_layout.commit_count()]; + + let graph_lines = print_graph_terminal( + &self.settings, + &self.topo.borrow(), + &track_layout, + &text_height, + ); + + self.geo = Some(track_layout); + self.doc = Some(graph_lines); + + // If a commit takes more than one row, then line count and commit count + // no longer match. If selection is at the last commit, this will be + // off screen. Adjust layout scroll so selection is always visible. + // + // NOTE: This implementation has the strange effect that an arrow up + // will auto-scroll which is probably not what the user expects. + let select_layout_commit = + selection.saturating_sub(first_commit); + self.row_scroll = self + .doc + .as_ref() + .unwrap() + .commit2line + .get(select_layout_commit) + .unwrap_or(&0) + .saturating_add(1) + .saturating_sub(height_in_lines); + } + + /// Get a graph from the specified offset row in layout + pub fn get_graph_line(&self, row: usize) -> Line<'static> { + let line_ref = self + .doc + .as_ref() + .and_then(|graph_lines| graph_lines.graph_lines.get(row)); + + let string_to_line = |line: &String| Line::from(line.clone()); + let default_line = || Line::from("%% no graph data %%"); + line_ref.map_or_else(default_line, string_to_line) + } + + /// First line that should be displayed, if you want the selection + /// to be visible. + pub fn row_scroll(&self) -> usize { + self.row_scroll + } + + /// Get the height of a commit in the layout + pub fn layout_commit_height( + &self, + layout_commit: usize, + ) -> usize { + let this_line = self.doc.as_ref().and_then(|graph_lines| { + graph_lines.commit2line.get(layout_commit) + }); + let next_line = self.doc.as_ref().and_then(|graph_lines| { + graph_lines.commit2line.get(layout_commit + 1) + }); + match (this_line, next_line) { + (Some(a), Some(b)) => b - a, + (Some(a), None) => { + self.doc + .as_ref() + .map(|gl| gl.graph_lines.len()) + .unwrap() - a + } + (None, _) => 0, + } + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 29485be1e4..1916f04a23 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 graph_cache; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; diff --git a/src/main.rs b/src/main.rs index fd662950a2..65b85797e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,8 +13,10 @@ //! - [components] for visual elements used on tabs //! - [popups] for temporary dialogs //! - [ui] for tooling like scrollbars +//! - [gleisbau] (crate) for rendering the branch graph //! - Git Interface //! - [asyncgit] (crate) for async operations on repository +//! - [gleisbau] (crate) repo traits //! - Distribution and Documentation //! - Project files //! - Github CI @@ -27,6 +29,7 @@ //! - git2-hooks (used by asyncgit). //! - git2-testing (used by git2-hooks). //! - invalidstring used by asyncgit for testing with invalid strings. +//! - [gleisbau] for rendering the branch graph //! - [filetreelist] for a tree view of files. //! - [scopetime] for measuring execution time. //!