diff --git a/Cargo.lock b/Cargo.lock index 628074a875a4a7d3ac0fea87dc5b1cf3bd85fcf1..97900b60263db0e58348b3d62bc3fbacecd31d78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7143,6 +7143,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "smallvec", "smol", "sum_tree", "tempfile", @@ -7170,6 +7171,29 @@ dependencies = [ "url", ] +[[package]] +name = "git_graph" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "db", + "fs", + "git", + "git_ui", + "gpui", + "project", + "rand 0.9.2", + "recent_projects", + "serde_json", + "settings", + "smallvec", + "theme", + "time", + "ui", + "workspace", +] + [[package]] name = "git_hosting_providers" version = "0.1.0" @@ -20856,6 +20880,7 @@ dependencies = [ "fs", "futures 0.3.31", "git", + "git_graph", "git_hosting_providers", "git_ui", "go_to_line", diff --git a/Cargo.toml b/Cargo.toml index c4a6648c51370b6de59283481e9f7b4e425e6b10..7e40776f4a395afd120c900ca3da2230a3bef48e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ members = [ "crates/fsevent", "crates/fuzzy", "crates/git", + "crates/git_graph", "crates/git_hosting_providers", "crates/git_ui", "crates/go_to_line", @@ -312,6 +313,7 @@ fs = { path = "crates/fs" } fsevent = { path = "crates/fsevent" } fuzzy = { path = "crates/fuzzy" } git = { path = "crates/git" } +git_graph = { path = "crates/git_graph" } git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index eda6fcec200cef450ed4c4243e2b9ad3656f0f0d..2057cd1d85958719255b46d441a31a80be4a95d0 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -6,8 +6,9 @@ use git::{ Oid, RunHook, blame::Blame, repository::{ - AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository, - GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree, + AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions, + GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, + LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree, }, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, @@ -18,7 +19,7 @@ use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task}; use ignore::gitignore::GitignoreBuilder; use parking_lot::Mutex; use rope::Rope; -use smol::future::FutureExt as _; +use smol::{channel::Sender, future::FutureExt as _}; use std::{path::PathBuf, sync::Arc}; use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; @@ -49,6 +50,7 @@ pub struct FakeGitRepositoryState { pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub refs: HashMap, + pub graph_commits: Vec>, } impl FakeGitRepositoryState { @@ -66,6 +68,7 @@ impl FakeGitRepositoryState { merge_base_contents: Default::default(), oids: Default::default(), remotes: HashMap::default(), + graph_commits: Vec::new(), } } } @@ -737,4 +740,28 @@ impl GitRepository for FakeGitRepository { Ok(()) }) } + + fn initial_graph_data( + &self, + _log_source: LogSource, + _log_order: LogOrder, + request_tx: Sender>>, + ) -> BoxFuture<'_, Result<()>> { + let fs = self.fs.clone(); + let dot_git_path = self.dot_git_path.clone(); + async move { + let graph_commits = + fs.with_git_state(&dot_git_path, false, |state| state.graph_commits.clone())?; + + for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) { + request_tx.send(chunk.to_vec()).await.ok(); + } + Ok(()) + } + .boxed() + } + + fn commit_data_reader(&self) -> Result { + anyhow::bail!("commit_data_reader not supported for FakeGitRepository") + } } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index ea09aa27abd1a8d0d1d9a6f587fe24d90e5f0d1f..f8eed421b582d462f0a69436aba1533e749a9cf0 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -54,7 +54,7 @@ use collections::{BTreeMap, btree_map}; use fake_git_repo::FakeGitRepositoryState; #[cfg(feature = "test-support")] use git::{ - repository::{RepoPath, repo_path}, + repository::{InitialGraphCommitData, RepoPath, repo_path}, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; @@ -2001,6 +2001,13 @@ impl FakeFs { .unwrap(); } + pub fn set_graph_commits(&self, dot_git: &Path, commits: Vec>) { + self.with_git_state(dot_git, true, |state| { + state.graph_commits = commits; + }) + .unwrap(); + } + /// Put the given git repository into a state with the given status, /// by mutating the head, index, and unmerged state. pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&str, FileStatus)]) { diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 39b68758744b5837c432b71c8e8bacd96a1da708..4d96312e274b3934e0d1ae8aa1f16f235d30a59f 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -31,6 +31,7 @@ rand = { workspace = true, optional = true } rope.workspace = true schemars.workspace = true serde.workspace = true +smallvec.workspace = true smol.workspace = true sum_tree.workspace = true text.workspace = true diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e2475dfc10ed08c719703d487e6a375fffec20ff..c727f7e9f6595cf0f46fa686641ec8465da845ad 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -4,6 +4,7 @@ use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff}; use crate::{Oid, RunHook, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use collections::HashMap; +use futures::channel::oneshot; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; @@ -13,12 +14,15 @@ use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; +use smallvec::SmallVec; +use smol::channel::Sender; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use text::LineEnding; use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::process::{ExitStatus, Stdio}; +use std::str::FromStr; use std::{ cmp::Ordering, future, @@ -37,6 +41,106 @@ pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession}; pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; +/// Format string used in graph log to get initial data for the git graph +/// %H - Full commit hash +/// %P - Parent hashes +/// %D - Ref names +/// %x00 - Null byte separator, used to split up commit data +static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; + +/// Number of commits to load per chunk for the git graph. +pub const GRAPH_CHUNK_SIZE: usize = 1000; + +/// Commit data needed for the git graph visualization. +#[derive(Debug, Clone)] +pub struct GraphCommitData { + pub sha: Oid, + /// Most commits have a single parent, so we use a SmallVec to avoid allocations. + pub parents: SmallVec<[Oid; 1]>, + pub author_name: SharedString, + pub author_email: SharedString, + pub commit_timestamp: i64, + pub subject: SharedString, +} + +#[derive(Debug)] +pub struct InitialGraphCommitData { + pub sha: Oid, + pub parents: SmallVec<[Oid; 1]>, + pub ref_names: Vec, +} + +struct CommitDataRequest { + sha: Oid, + response_tx: oneshot::Sender>, +} + +pub struct CommitDataReader { + request_tx: smol::channel::Sender, + _task: Task<()>, +} + +impl CommitDataReader { + pub async fn read(&self, sha: Oid) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.request_tx + .send(CommitDataRequest { sha, response_tx }) + .await + .map_err(|_| anyhow!("commit data reader task closed"))?; + response_rx + .await + .map_err(|_| anyhow!("commit data reader task dropped response"))? + } +} + +fn parse_cat_file_commit(sha: Oid, content: &str) -> Option { + let mut parents = SmallVec::new(); + let mut author_name = SharedString::default(); + let mut author_email = SharedString::default(); + let mut commit_timestamp = 0i64; + let mut in_headers = true; + let mut subject = None; + + for line in content.lines() { + if in_headers { + if line.is_empty() { + in_headers = false; + continue; + } + + if let Some(parent_sha) = line.strip_prefix("parent ") { + if let Ok(oid) = Oid::from_str(parent_sha.trim()) { + parents.push(oid); + } + } else if let Some(author_line) = line.strip_prefix("author ") { + if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') { + if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') { + if let Ok(ts) = timestamp_str.parse::() { + commit_timestamp = ts; + } + if let Some((name, email)) = name_email.rsplit_once(" <") { + author_name = SharedString::from(name.to_string()); + author_email = + SharedString::from(email.trim_end_matches('>').to_string()); + } + } + } + } + } else if subject.is_none() { + subject = Some(SharedString::from(line.to_string())); + } + } + + Some(GraphCommitData { + sha, + parents, + author_name, + author_email, + commit_timestamp, + subject: subject.unwrap_or_default(), + }) +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Branch { pub is_head: bool, @@ -420,6 +524,46 @@ impl Drop for GitExcludeOverride { } } +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)] +pub enum LogOrder { + #[default] + DateOrder, + TopoOrder, + AuthorDateOrder, + ReverseChronological, +} + +impl LogOrder { + pub fn as_arg(&self) -> &'static str { + match self { + LogOrder::DateOrder => "--date-order", + LogOrder::TopoOrder => "--topo-order", + LogOrder::AuthorDateOrder => "--author-date-order", + LogOrder::ReverseChronological => "--reverse", + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum LogSource { + #[default] + All, + Branch(SharedString), + Sha(Oid), +} + +impl LogSource { + fn get_arg(&self) -> Result<&str> { + match self { + LogSource::All => Ok("--all"), + LogSource::Branch(branch) => Ok(branch.as_str()), + LogSource::Sha(oid) => { + str::from_utf8(oid.as_bytes()).context("Failed to build str from sha") + } + } + } +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -653,6 +797,17 @@ pub trait GitRepository: Send + Sync { &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>>; + + /// Runs `git rev-list --parents` to get the commit graph structure. + /// Returns commit SHAs and their parent SHAs for building the graph visualization. + fn initial_graph_data( + &self, + log_source: LogSource, + log_order: LogOrder, + request_tx: Sender>>, + ) -> BoxFuture<'_, Result<()>>; + + fn commit_data_reader(&self) -> Result; } pub enum DiffType { @@ -2412,6 +2567,215 @@ impl GitRepository for RealGitRepository { } .boxed() } + + fn initial_graph_data( + &self, + log_source: LogSource, + log_order: LogOrder, + request_tx: Sender>>, + ) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + + async move { + let working_directory = working_directory?; + let git = GitBinary::new(git_binary_path, working_directory, executor); + + let mut command = git.build_command([ + "log", + GRAPH_COMMIT_FORMAT, + log_order.as_arg(), + log_source.get_arg()?, + ]); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().context("failed to get stdout")?; + let mut reader = BufReader::new(stdout); + + let mut line_buffer = String::new(); + let mut lines: Vec = Vec::with_capacity(GRAPH_CHUNK_SIZE); + + loop { + line_buffer.clear(); + let bytes_read = reader.read_line(&mut line_buffer).await?; + + if bytes_read == 0 { + if !lines.is_empty() { + let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str())); + if request_tx.send(commits).await.is_err() { + log::warn!( + "initial_graph_data: receiver dropped while sending commits" + ); + } + } + break; + } + + let line = line_buffer.trim_end_matches('\n').to_string(); + lines.push(line); + + if lines.len() >= GRAPH_CHUNK_SIZE { + let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str())); + if request_tx.send(commits).await.is_err() { + log::warn!("initial_graph_data: receiver dropped while streaming commits"); + break; + } + lines.clear(); + } + } + + child.status().await?; + Ok(()) + } + .boxed() + } + + fn commit_data_reader(&self) -> Result { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self + .working_directory() + .map_err(|_| anyhow!("no working directory"))?; + let executor = self.executor.clone(); + + let (request_tx, request_rx) = smol::channel::bounded::(64); + + let task = self.executor.spawn(async move { + if let Err(error) = + run_commit_data_reader(git_binary_path, working_directory, executor, request_rx) + .await + { + log::error!("commit data reader failed: {error:?}"); + } + }); + + Ok(CommitDataReader { + request_tx, + _task: task, + }) + } +} + +async fn run_commit_data_reader( + git_binary_path: PathBuf, + working_directory: PathBuf, + executor: BackgroundExecutor, + request_rx: smol::channel::Receiver, +) -> Result<()> { + let git = GitBinary::new(git_binary_path, working_directory, executor); + let mut process = git + .build_command(["--no-optional-locks", "cat-file", "--batch"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting git cat-file --batch process")?; + + let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?); + let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?); + + const MAX_BATCH_SIZE: usize = 64; + + while let Ok(first_request) = request_rx.recv().await { + let mut pending_requests = vec![first_request]; + + while pending_requests.len() < MAX_BATCH_SIZE { + match request_rx.try_recv() { + Ok(request) => pending_requests.push(request), + Err(_) => break, + } + } + + for request in &pending_requests { + stdin.write_all(request.sha.to_string().as_bytes()).await?; + stdin.write_all(b"\n").await?; + } + stdin.flush().await?; + + for request in pending_requests { + let result = read_single_commit_response(&mut stdout, &request.sha).await; + request.response_tx.send(result).ok(); + } + } + + drop(stdin); + process.kill().ok(); + + Ok(()) +} + +async fn read_single_commit_response( + stdout: &mut BufReader, + sha: &Oid, +) -> Result { + let mut header_bytes = Vec::new(); + stdout.read_until(b'\n', &mut header_bytes).await?; + let header_line = String::from_utf8_lossy(&header_bytes); + + let parts: Vec<&str> = header_line.trim().split(' ').collect(); + if parts.len() < 3 { + bail!("invalid cat-file header: {header_line}"); + } + + let object_type = parts[1]; + if object_type == "missing" { + bail!("object not found: {}", sha); + } + + if object_type != "commit" { + bail!("expected commit object, got {object_type}"); + } + + let size: usize = parts[2] + .parse() + .with_context(|| format!("invalid object size: {}", parts[2]))?; + + let mut content = vec![0u8; size]; + stdout.read_exact(&mut content).await?; + + let mut newline = [0u8; 1]; + stdout.read_exact(&mut newline).await?; + + let content_str = String::from_utf8_lossy(&content); + parse_cat_file_commit(*sha, &content_str) + .ok_or_else(|| anyhow!("failed to parse commit {}", sha)) +} + +fn parse_initial_graph_output<'a>( + lines: impl Iterator, +) -> Vec> { + lines + .filter(|line| !line.is_empty()) + .filter_map(|line| { + // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..." + let mut parts = line.split('\x00'); + + let sha = Oid::from_str(parts.next()?).ok()?; + let parents_str = parts.next()?; + let parents = parents_str + .split_whitespace() + .filter_map(|p| Oid::from_str(p).ok()) + .collect(); + + let ref_names_str = parts.next().unwrap_or(""); + let ref_names = if ref_names_str.is_empty() { + Vec::new() + } else { + ref_names_str + .split(", ") + .map(|s| SharedString::from(s.to_string())) + .collect() + }; + + Some(Arc::new(InitialGraphCommitData { + sha, + parents, + ref_names, + })) + }) + .collect() } fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f3fb46ffe477b4c46b0a4f3f2173b3c06f8bb4ae --- /dev/null +++ b/crates/git_graph/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "git_graph" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/git_graph.rs" + +[features] +default = [] +test-support = [ + "project/test-support", + "gpui/test-support", +] + +[dependencies] +anyhow.workspace = true +collections.workspace = true +db.workspace = true +git.workspace = true +git_ui.workspace = true +gpui.workspace = true +project.workspace = true +settings.workspace = true +smallvec.workspace = true +theme.workspace = true +time.workspace = true +ui.workspace = true +workspace.workspace = true + +[dev-dependencies] +db = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +git = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +rand.workspace = true +recent_projects = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_graph/LICENSE-GPL b/crates/git_graph/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/git_graph/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fda56c08680629a2f4c220b87864dfec2d1d066 --- /dev/null +++ b/crates/git_graph/src/git_graph.rs @@ -0,0 +1,2359 @@ +use collections::{BTreeMap, HashMap}; +use git::{ + BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, + parse_git_remote_url, + repository::{CommitDiff, InitialGraphCommitData, LogOrder, LogSource}, +}; +use git_ui::commit_tooltip::CommitAvatar; +use gpui::{ + AnyElement, App, Bounds, ClipboardItem, Context, Corner, DefiniteLength, ElementId, Entity, + EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, ParentElement, + PathBuilder, Pixels, Point, Render, ScrollWheelEvent, SharedString, Styled, Subscription, Task, + WeakEntity, Window, actions, anchored, deferred, point, px, +}; +use project::{ + Project, + git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent}, +}; +use settings::Settings; +use smallvec::{SmallVec, smallvec}; +use std::{ops::Range, rc::Rc, sync::Arc, sync::OnceLock}; +use theme::{AccentColors, ThemeSettings}; +use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; +use ui::{ContextMenu, ScrollableHandle, Table, TableInteractionState, Tooltip, prelude::*}; +use workspace::{ + Workspace, + item::{Item, ItemEvent, SerializableItem}, +}; + +const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5); +const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5); +const LANE_WIDTH: Pixels = px(16.0); +const LEFT_PADDING: Pixels = px(12.0); +const LINE_WIDTH: Pixels = px(1.5); + +actions!( + git_graph, + [ + /// Opens the Git Graph panel. + Open, + /// Opens the commit view for the selected commit. + OpenCommitView, + ] +); + +fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { + static FORMAT: OnceLock>> = OnceLock::new(); + FORMAT.get_or_init(|| { + time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]") + .unwrap_or_default() + }) +} + +fn format_timestamp(timestamp: i64) -> String { + let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else { + return "Unknown".to_string(); + }; + + let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + let local_datetime = datetime.to_offset(local_offset); + + local_datetime + .format(timestamp_format()) + .unwrap_or_default() +} + +fn accent_colors_count(accents: &AccentColors) -> usize { + accents.0.len() +} + +#[derive(Copy, Clone, Debug)] +struct BranchColor(u8); + +#[derive(Debug)] +enum LaneState { + Empty, + Active { + child: Oid, + parent: Oid, + color: Option, + starting_row: usize, + starting_col: usize, + destination_column: Option, + segments: SmallVec<[CommitLineSegment; 1]>, + }, +} + +impl LaneState { + fn to_commit_lines( + &mut self, + ending_row: usize, + lane_column: usize, + parent_column: usize, + parent_color: BranchColor, + ) -> Option { + let state = std::mem::replace(self, LaneState::Empty); + + match state { + LaneState::Active { + #[cfg_attr(not(test), allow(unused_variables))] + parent, + #[cfg_attr(not(test), allow(unused_variables))] + child, + color, + starting_row, + starting_col, + destination_column, + mut segments, + } => { + let final_destination = destination_column.unwrap_or(parent_column); + let final_color = color.unwrap_or(parent_color); + + Some(CommitLine { + #[cfg(test)] + child, + #[cfg(test)] + parent, + child_column: starting_col, + full_interval: starting_row..ending_row, + color_idx: final_color.0 as usize, + segments: { + match segments.last_mut() { + Some(CommitLineSegment::Straight { to_row }) + if *to_row == usize::MAX => + { + if final_destination != lane_column { + *to_row = ending_row - 1; + + let curved_line = CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }; + + if *to_row == starting_row { + let last_index = segments.len() - 1; + segments[last_index] = curved_line; + } else { + segments.push(curved_line); + } + } else { + *to_row = ending_row; + } + } + Some(CommitLineSegment::Curve { + on_row, + to_column, + curve_kind, + }) if *on_row == usize::MAX => { + if *to_column == usize::MAX { + *to_column = final_destination; + } + if matches!(curve_kind, CurveKind::Merge) { + *on_row = starting_row + 1; + if *on_row < ending_row { + if *to_column != final_destination { + segments.push(CommitLineSegment::Straight { + to_row: ending_row - 1, + }); + segments.push(CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }); + } else { + segments.push(CommitLineSegment::Straight { + to_row: ending_row, + }); + } + } else if *to_column != final_destination { + segments.push(CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }); + } + } else { + *on_row = ending_row; + if *to_column != final_destination { + segments.push(CommitLineSegment::Straight { + to_row: ending_row, + }); + segments.push(CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }); + } + } + } + Some(CommitLineSegment::Curve { + on_row, to_column, .. + }) => { + if *on_row < ending_row { + if *to_column != final_destination { + segments.push(CommitLineSegment::Straight { + to_row: ending_row - 1, + }); + segments.push(CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }); + } else { + segments.push(CommitLineSegment::Straight { + to_row: ending_row, + }); + } + } else if *to_column != final_destination { + segments.push(CommitLineSegment::Curve { + to_column: final_destination, + on_row: ending_row, + curve_kind: CurveKind::Checkout, + }); + } + } + _ => {} + } + + segments + }, + }) + } + LaneState::Empty => None, + } + } + + fn is_empty(&self) -> bool { + match self { + LaneState::Empty => true, + LaneState::Active { .. } => false, + } + } +} + +struct CommitEntry { + data: Arc, + lane: usize, + color_idx: usize, +} + +type ActiveLaneIdx = usize; + +enum AllCommitCount { + NotLoaded, + Loaded(usize), +} + +#[derive(Debug)] +enum CurveKind { + Merge, + Checkout, +} + +#[derive(Debug)] +enum CommitLineSegment { + Straight { + to_row: usize, + }, + Curve { + to_column: usize, + on_row: usize, + curve_kind: CurveKind, + }, +} + +#[derive(Debug)] +struct CommitLine { + #[cfg(test)] + child: Oid, + #[cfg(test)] + parent: Oid, + child_column: usize, + full_interval: Range, + color_idx: usize, + segments: SmallVec<[CommitLineSegment; 1]>, +} + +impl CommitLine { + fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> { + if first_visible_row > self.full_interval.end { + return None; + } else if first_visible_row <= self.full_interval.start { + return Some((0, self.child_column)); + } + + let mut current_column = self.child_column; + + for (idx, segment) in self.segments.iter().enumerate() { + match segment { + CommitLineSegment::Straight { to_row } => { + if *to_row >= first_visible_row { + return Some((idx, current_column)); + } + } + CommitLineSegment::Curve { + to_column, on_row, .. + } => { + if *on_row >= first_visible_row { + return Some((idx, current_column)); + } + current_column = *to_column; + } + } + } + + None + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct CommitLineKey { + child: Oid, + parent: Oid, +} + +struct GraphData { + lane_states: SmallVec<[LaneState; 8]>, + lane_colors: HashMap, + parent_to_lanes: HashMap>, + next_color: BranchColor, + accent_colors_count: usize, + commits: Vec>, + max_commit_count: AllCommitCount, + max_lanes: usize, + lines: Vec>, + active_commit_lines: HashMap, + active_commit_lines_by_parent: HashMap>, +} + +impl GraphData { + fn new(accent_colors_count: usize) -> Self { + GraphData { + lane_states: SmallVec::default(), + lane_colors: HashMap::default(), + parent_to_lanes: HashMap::default(), + next_color: BranchColor(0), + accent_colors_count, + commits: Vec::default(), + max_commit_count: AllCommitCount::NotLoaded, + max_lanes: 0, + lines: Vec::default(), + active_commit_lines: HashMap::default(), + active_commit_lines_by_parent: HashMap::default(), + } + } + + fn clear(&mut self) { + self.lane_states.clear(); + self.lane_colors.clear(); + self.parent_to_lanes.clear(); + self.commits.clear(); + self.lines.clear(); + self.active_commit_lines.clear(); + self.active_commit_lines_by_parent.clear(); + self.next_color = BranchColor(0); + self.max_commit_count = AllCommitCount::NotLoaded; + self.max_lanes = 0; + } + + fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx { + self.lane_states + .iter() + .position(LaneState::is_empty) + .unwrap_or_else(|| { + self.lane_states.push(LaneState::Empty); + self.lane_states.len() - 1 + }) + } + + fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor { + let accent_colors_count = self.accent_colors_count; + *self.lane_colors.entry(lane_idx).or_insert_with(|| { + let color_idx = self.next_color; + self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8); + color_idx + }) + } + + fn add_commits(&mut self, commits: &[Arc]) { + self.commits.reserve(commits.len()); + self.lines.reserve(commits.len() / 2); + + for commit in commits.iter() { + let commit_row = self.commits.len(); + + let commit_lane = self + .parent_to_lanes + .get(&commit.sha) + .and_then(|lanes| lanes.first().copied()); + + let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx()); + + let commit_color = self.get_lane_color(commit_lane); + + if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) { + for lane_column in lanes { + let state = &mut self.lane_states[lane_column]; + + if let LaneState::Active { + starting_row, + segments, + .. + } = state + { + if let Some(CommitLineSegment::Curve { + to_column, + curve_kind: CurveKind::Merge, + .. + }) = segments.first_mut() + { + let curve_row = *starting_row + 1; + let would_overlap = + if lane_column != commit_lane && curve_row < commit_row { + self.commits[curve_row..commit_row] + .iter() + .any(|c| c.lane == commit_lane) + } else { + false + }; + + if would_overlap { + *to_column = lane_column; + } + } + } + + if let Some(commit_line) = + state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color) + { + self.lines.push(Rc::new(commit_line)); + } + } + } + + commit + .parents + .iter() + .enumerate() + .for_each(|(parent_idx, parent)| { + if parent_idx == 0 { + self.lane_states[commit_lane] = LaneState::Active { + parent: *parent, + child: commit.sha, + color: Some(commit_color), + starting_col: commit_lane, + starting_row: commit_row, + destination_column: None, + segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }], + }; + + self.parent_to_lanes + .entry(*parent) + .or_default() + .push(commit_lane); + } else { + let new_lane = self.first_empty_lane_idx(); + + self.lane_states[new_lane] = LaneState::Active { + parent: *parent, + child: commit.sha, + color: None, + starting_col: commit_lane, + starting_row: commit_row, + destination_column: None, + segments: smallvec![CommitLineSegment::Curve { + to_column: usize::MAX, + on_row: usize::MAX, + curve_kind: CurveKind::Merge, + },], + }; + + self.parent_to_lanes + .entry(*parent) + .or_default() + .push(new_lane); + } + }); + + self.max_lanes = self.max_lanes.max(self.lane_states.len()); + + self.commits.push(Rc::new(CommitEntry { + data: commit.clone(), + lane: commit_lane, + color_idx: commit_color.0 as usize, + })); + } + } +} + +pub fn init(cx: &mut App) { + workspace::register_serializable_item::(cx); + + cx.observe_new(|workspace: &mut workspace::Workspace, _, _| { + workspace.register_action_renderer(|div, workspace, _, cx| { + div.when( + workspace.project().read(cx).active_repository(cx).is_some(), + |div| { + let workspace = workspace.weak_handle(); + + div.on_action(move |_: &Open, window, cx| { + workspace + .update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let git_graph = cx.new(|cx| GitGraph::new(project, window, cx)); + workspace.add_item_to_active_pane( + Box::new(git_graph), + None, + true, + window, + cx, + ); + }) + .ok(); + }) + }, + ) + }); + }) + .detach(); +} + +fn lane_center_x(bounds: Bounds, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels { + bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset +} + +fn to_row_center( + to_row: usize, + row_height: Pixels, + scroll_offset: Pixels, + bounds: Bounds, +) -> Pixels { + bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset +} + +fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) { + let radius = COMMIT_CIRCLE_RADIUS; + let stroke_width = COMMIT_CIRCLE_STROKE_WIDTH; + + let mut builder = PathBuilder::stroke(stroke_width); + + // Start at the rightmost point of the circle + builder.move_to(point(center_x + radius, center_y)); + + // Draw the circle using two arc_to calls (top half, then bottom half) + builder.arc_to( + point(radius, radius), + px(0.), + false, + true, + point(center_x - radius, center_y), + ); + builder.arc_to( + point(radius, radius), + px(0.), + false, + true, + point(center_x + radius, center_y), + ); + builder.close(); + + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +pub struct GitGraph { + focus_handle: FocusHandle, + graph_data: GraphData, + project: Entity, + context_menu: Option<(Entity, Point, Subscription)>, + row_height: Pixels, + table_interaction_state: Entity, + horizontal_scroll_offset: Pixels, + graph_viewport_width: Pixels, + selected_entry_idx: Option, + log_source: LogSource, + log_order: LogOrder, + selected_commit_diff: Option, + _commit_diff_task: Option>, + _load_task: Option>, +} + +impl GitGraph { + fn row_height(cx: &App) -> Pixels { + let settings = ThemeSettings::get_global(cx); + let font_size = settings.buffer_font_size(cx); + font_size + px(12.0) + } + + fn graph_content_width(&self) -> Pixels { + (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0 + } + + pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) + .detach(); + + let git_store = project.read(cx).git_store().clone(); + let accent_colors = cx.theme().accents(); + let mut graph = GraphData::new(accent_colors_count(accent_colors)); + let log_source = LogSource::default(); + let log_order = LogOrder::default(); + + cx.subscribe(&git_store, |this, _, event, cx| match event { + GitStoreEvent::RepositoryUpdated(_, repo_event, is_active) => { + if *is_active { + if let Some(repository) = this.project.read(cx).active_repository(cx) { + this.on_repository_event(repository, repo_event, cx); + } + } + } + GitStoreEvent::ActiveRepositoryChanged(_) => { + this.graph_data.clear(); + cx.notify(); + } + _ => {} + }) + .detach(); + + if let Some(repository) = project.read(cx).active_repository(cx) { + repository.update(cx, |repository, cx| { + let commits = + repository.graph_data(log_source.clone(), log_order, 0..usize::MAX, cx); + graph.add_commits(commits); + }); + } + + let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); + let mut row_height = Self::row_height(cx); + + cx.observe_global_in::(window, move |this, _window, cx| { + let new_row_height = Self::row_height(cx); + if new_row_height != row_height { + this.row_height = new_row_height; + this.table_interaction_state.update(cx, |state, _cx| { + state.scroll_handle.0.borrow_mut().last_item_size = None; + }); + row_height = new_row_height; + } + cx.notify(); + }) + .detach(); + + GitGraph { + focus_handle, + project, + graph_data: graph, + _load_task: None, + _commit_diff_task: None, + context_menu: None, + row_height, + table_interaction_state, + horizontal_scroll_offset: px(0.), + graph_viewport_width: px(88.), + selected_entry_idx: None, + selected_commit_diff: None, + log_source, + log_order, + } + } + + fn on_repository_event( + &mut self, + repository: Entity, + event: &RepositoryEvent, + cx: &mut Context, + ) { + match event { + RepositoryEvent::GitGraphCountUpdated(_, commit_count) => { + let old_count = self.graph_data.commits.len(); + + repository.update(cx, |repository, cx| { + let commits = repository.graph_data( + self.log_source.clone(), + self.log_order, + old_count..*commit_count, + cx, + ); + self.graph_data.add_commits(commits); + }); + + self.graph_data.max_commit_count = AllCommitCount::Loaded(*commit_count); + } + RepositoryEvent::BranchChanged => { + self.graph_data.clear(); + cx.notify(); + } + _ => {} + } + } + + fn render_badge(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement { + div() + .px_1p5() + .py_0p5() + .h(self.row_height - px(4.0)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .bg(accent_color.opacity(0.18)) + .border_1() + .border_color(accent_color.opacity(0.55)) + .child( + Label::new(name.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .single_line(), + ) + } + + fn render_table_rows( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec> { + let repository = self + .project + .read_with(cx, |project, cx| project.active_repository(cx)); + + let row_height = self.row_height; + + // We fetch data outside the visible viewport to avoid loading entries when + // users scroll through the git graph + if let Some(repository) = repository.as_ref() { + const FETCH_RANGE: usize = 100; + repository.update(cx, |repository, cx| { + self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE) + ..(range.end + FETCH_RANGE) + .min(self.graph_data.commits.len().saturating_sub(1))] + .iter() + .for_each(|commit| { + repository.fetch_commit_data(commit.data.sha, cx); + }); + }); + } + + range + .map(|idx| { + let Some((commit, repository)) = + self.graph_data.commits.get(idx).zip(repository.as_ref()) + else { + return vec![ + div().h(row_height).into_any_element(), + div().h(row_height).into_any_element(), + div().h(row_height).into_any_element(), + div().h(row_height).into_any_element(), + ]; + }; + + let data = repository.update(cx, |repository, cx| { + repository.fetch_commit_data(commit.data.sha, cx).clone() + }); + + let short_sha = commit.data.sha.display_short(); + let mut formatted_time = String::new(); + let subject; + let author_name; + + if let CommitDataState::Loaded(data) = data { + subject = data.subject.clone(); + author_name = data.author_name.clone(); + formatted_time = format_timestamp(data.commit_timestamp); + } else { + subject = "Loading...".into(); + author_name = "".into(); + } + + let accent_colors = cx.theme().accents(); + let accent_color = accent_colors + .0 + .get(commit.color_idx) + .copied() + .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); + let is_selected = self.selected_entry_idx == Some(idx); + let text_color = if is_selected { + Color::Default + } else { + Color::Muted + }; + + vec![ + div() + .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) + .overflow_hidden() + .tooltip(Tooltip::text(subject.clone())) + .child( + h_flex() + .gap_1() + .items_center() + .overflow_hidden() + .children((!commit.data.ref_names.is_empty()).then(|| { + h_flex().flex_shrink().gap_2().items_center().children( + commit + .data + .ref_names + .iter() + .map(|name| self.render_badge(name, accent_color)), + ) + })) + .child( + Label::new(subject) + .color(text_color) + .truncate() + .single_line(), + ), + ) + .into_any_element(), + Label::new(formatted_time) + .color(text_color) + .single_line() + .into_any_element(), + Label::new(author_name) + .color(text_color) + .single_line() + .into_any_element(), + Label::new(short_sha) + .color(text_color) + .single_line() + .into_any_element(), + ] + }) + .collect() + } + + fn select_entry(&mut self, idx: usize, cx: &mut Context) { + if self.selected_entry_idx == Some(idx) { + return; + } + + self.selected_entry_idx = Some(idx); + self.selected_commit_diff = None; + + let Some(commit) = self.graph_data.commits.get(idx) else { + return; + }; + + let sha = commit.data.sha.to_string(); + let repository = self + .project + .read_with(cx, |project, cx| project.active_repository(cx)); + + let Some(repository) = repository else { + return; + }; + + let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha)); + + self._commit_diff_task = Some(cx.spawn(async move |this, cx| { + if let Ok(Ok(diff)) = diff_receiver.await { + this.update(cx, |this, cx| { + this.selected_commit_diff = Some(diff); + cx.notify(); + }) + .ok(); + } + })); + + cx.notify(); + } + + fn get_remote( + &self, + repository: &Repository, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let remote_url = repository.default_remote_url()?; + let provider_registry = GitHostingProviderRegistry::default_global(cx); + let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; + Some(GitRemote { + host: provider, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + } + + fn render_commit_detail_panel( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let Some(selected_idx) = self.selected_entry_idx else { + return div().into_any_element(); + }; + + let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else { + return div().into_any_element(); + }; + + let repository = self + .project + .read_with(cx, |project, cx| project.active_repository(cx)); + + let Some(repository) = repository else { + return div().into_any_element(); + }; + + let data = repository.update(cx, |repository, cx| { + repository + .fetch_commit_data(commit_entry.data.sha, cx) + .clone() + }); + + let full_sha: SharedString = commit_entry.data.sha.to_string().into(); + let truncated_sha: SharedString = { + let sha_str = full_sha.as_ref(); + if sha_str.len() > 24 { + format!("{}...", &sha_str[..24]).into() + } else { + full_sha.clone() + } + }; + let ref_names = commit_entry.data.ref_names.clone(); + let accent_colors = cx.theme().accents(); + let accent_color = accent_colors + .0 + .get(commit_entry.color_idx) + .copied() + .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); + + let (author_name, author_email, commit_timestamp, subject) = match &data { + CommitDataState::Loaded(data) => ( + data.author_name.clone(), + data.author_email.clone(), + Some(data.commit_timestamp), + data.subject.clone(), + ), + CommitDataState::Loading => ("Loading...".into(), "".into(), None, "Loading...".into()), + }; + + let date_string = commit_timestamp + .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok()) + .map(|datetime| { + let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + let local_datetime = datetime.to_offset(local_offset); + let format = + time::format_description::parse("[month repr:short] [day], [year]").ok(); + format + .and_then(|f| local_datetime.format(&f).ok()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx)); + + let avatar = { + let avatar = CommitAvatar::new(&full_sha, remote.as_ref()); + v_flex() + .w(px(64.)) + .h(px(64.)) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_full() + .justify_center() + .items_center() + .child( + avatar + .avatar(window, cx) + .map(|a| a.size(px(64.)).into_any_element()) + .unwrap_or_else(|| { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element() + }), + ) + }; + + let changed_files_count = self + .selected_commit_diff + .as_ref() + .map(|diff| diff.files.len()) + .unwrap_or(0); + + v_flex() + .w(px(300.)) + .h_full() + .border_l_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().surface_background) + .child( + v_flex() + .p_3() + .gap_3() + .child( + h_flex().justify_between().child(avatar).child( + IconButton::new("close-detail", IconName::Close) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_entry_idx = None; + this.selected_commit_diff = None; + this._commit_diff_task = None; + cx.notify(); + })), + ), + ) + .child( + v_flex() + .gap_0p5() + .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD)) + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .children((!ref_names.is_empty()).then(|| { + h_flex().gap_1().flex_wrap().children( + ref_names + .iter() + .map(|name| self.render_badge(name, accent_color)), + ) + })) + .child( + v_flex() + .gap_1p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Person) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(author_name) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when(!author_email.is_empty(), |this| { + this.child( + Label::new(format!("<{}>", author_email)) + .size(LabelSize::Small) + .color(Color::Ignored), + ) + }), + ) + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Hash) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child({ + let copy_sha = full_sha.clone(); + Button::new("sha-button", truncated_sha) + .style(ButtonStyle::Transparent) + .label_size(LabelSize::Small) + .color(Color::Muted) + .tooltip(Tooltip::text(format!( + "Copy SHA: {}", + copy_sha + ))) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + copy_sha.to_string(), + )); + }) + }), + ) + .when_some(remote.clone(), |this, remote| { + let provider_name = remote.host.name(); + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), + repo: remote.repo.as_ref().into(), + }; + let params = BuildCommitPermalinkParams { + sha: full_sha.as_ref(), + }; + let url = remote + .host + .build_commit_permalink(&parsed_remote, params) + .to_string(); + this.child( + h_flex() + .gap_1() + .child( + Icon::new(icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Button::new( + "view-on-provider", + format!("View on {}", provider_name), + ) + .style(ButtonStyle::Transparent) + .label_size(LabelSize::Small) + .color(Color::Muted) + .on_click( + move |_, _, cx| { + cx.open_url(&url); + }, + ), + ), + ) + }), + ), + ) + .child( + div() + .border_t_1() + .border_color(cx.theme().colors().border) + .p_3() + .child( + v_flex() + .gap_2() + .child(Label::new(subject).weight(FontWeight::MEDIUM)), + ), + ) + .child( + div() + .flex_1() + .overflow_hidden() + .border_t_1() + .border_color(cx.theme().colors().border) + .p_3() + .child( + v_flex() + .gap_2() + .child( + Label::new(format!("{} Changed Files", changed_files_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(self.selected_commit_diff.as_ref().map(|diff| { + v_flex().gap_1().children(diff.files.iter().map(|file| { + let file_name: String = file + .path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_default(); + let dir_path: String = file + .path + .parent() + .map(|p| p.as_unix_str().to_string()) + .unwrap_or_default(); + + h_flex() + .gap_1() + .overflow_hidden() + .child( + Icon::new(IconName::File) + .size(IconSize::Small) + .color(Color::Accent), + ) + .child( + Label::new(file_name) + .size(LabelSize::Small) + .single_line(), + ) + .when(!dir_path.is_empty(), |this| { + this.child( + Label::new(dir_path) + .size(LabelSize::Small) + .color(Color::Muted) + .single_line(), + ) + }) + })) + })), + ), + ) + .into_any_element() + } + + pub fn render_graph(&self, cx: &mut Context) -> impl IntoElement { + let row_height = self.row_height; + let table_state = self.table_interaction_state.read(cx); + let viewport_height = table_state + .scroll_handle + .0 + .borrow() + .last_item_size + .map(|size| size.item.height) + .unwrap_or(px(600.0)); + let loaded_commit_count = self.graph_data.commits.len(); + + let content_height = row_height * loaded_commit_count; + let max_scroll = (content_height - viewport_height).max(px(0.)); + let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll); + + let first_visible_row = (scroll_offset_y / row_height).floor() as usize; + let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); + let horizontal_scroll_offset = self.horizontal_scroll_offset; + + let max_lanes = self.graph_data.max_lanes.max(6); + let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; + let last_visible_row = + first_visible_row + (viewport_height / row_height).ceil() as usize + 1; + + let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1)) + ..(last_visible_row).min(loaded_commit_count); + let rows = self.graph_data.commits[viewport_range.clone()].to_vec(); + let commit_lines: Vec<_> = self + .graph_data + .lines + .iter() + .filter(|line| { + line.full_interval.start <= viewport_range.end + && line.full_interval.end >= viewport_range.start + }) + .cloned() + .collect(); + + let mut lines: BTreeMap> = BTreeMap::new(); + + gpui::canvas( + move |_bounds, _window, _cx| {}, + move |bounds: Bounds, _: (), window: &mut Window, cx: &mut App| { + window.paint_layer(bounds, |window| { + let accent_colors = cx.theme().accents(); + + for (row_idx, row) in rows.into_iter().enumerate() { + let row_color = accent_colors.color_for_index(row.color_idx as u32); + let row_y_center = + bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0 + - vertical_scroll_offset; + + let commit_x = + lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset); + + draw_commit_circle(commit_x, row_y_center, row_color, window); + } + + for line in commit_lines { + let Some((start_segment_idx, start_column)) = + line.get_first_visible_segment_idx(first_visible_row) + else { + continue; + }; + + let line_x = + lane_center_x(bounds, start_column as f32, horizontal_scroll_offset); + + let start_row = line.full_interval.start as i32 - first_visible_row as i32; + + let from_y = + bounds.origin.y + start_row as f32 * row_height + row_height / 2.0 + - vertical_scroll_offset + + COMMIT_CIRCLE_RADIUS; + + let mut current_row = from_y; + let mut current_column = line_x; + + let mut builder = PathBuilder::stroke(LINE_WIDTH); + builder.move_to(point(line_x, from_y)); + + let segments = &line.segments[start_segment_idx..]; + + for (segment_idx, segment) in segments.iter().enumerate() { + let is_last = segment_idx + 1 == segments.len(); + + match segment { + CommitLineSegment::Straight { to_row } => { + let mut dest_row = to_row_center( + to_row - first_visible_row, + row_height, + vertical_scroll_offset, + bounds, + ); + if is_last { + dest_row -= COMMIT_CIRCLE_RADIUS; + } + + let dest_point = point(current_column, dest_row); + + current_row = dest_point.y; + builder.line_to(dest_point); + builder.move_to(dest_point); + } + CommitLineSegment::Curve { + to_column, + on_row, + curve_kind, + } => { + let mut to_column = lane_center_x( + bounds, + *to_column as f32, + horizontal_scroll_offset, + ); + + let mut to_row = to_row_center( + *on_row - first_visible_row, + row_height, + vertical_scroll_offset, + bounds, + ); + + // This means that this branch was a checkout + let going_right = to_column > current_column; + let column_shift = if going_right { + COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH + } else { + -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH + }; + + let control = match curve_kind { + CurveKind::Checkout => { + if is_last { + to_column -= column_shift; + } + builder.move_to(point(current_column, current_row)); + point(current_column, to_row) + } + CurveKind::Merge => { + if is_last { + to_row -= COMMIT_CIRCLE_RADIUS; + } + builder.move_to(point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + )); + point(to_column, current_row) + } + }; + + match curve_kind { + CurveKind::Checkout + if (to_row - current_row).abs() > row_height => + { + let start_curve = + point(current_column, current_row + row_height); + builder.line_to(start_curve); + builder.move_to(start_curve); + } + CurveKind::Merge + if (to_column - current_column).abs() > LANE_WIDTH => + { + let column_shift = + if going_right { LANE_WIDTH } else { -LANE_WIDTH }; + + let start_curve = point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + ); + + builder.line_to(start_curve); + builder.move_to(start_curve); + } + _ => {} + }; + + builder.curve_to(point(to_column, to_row), control); + current_row = to_row; + current_column = to_column; + builder.move_to(point(current_column, current_row)); + } + } + } + + builder.close(); + lines.entry(line.color_idx).or_default().push(builder); + } + + for (color_idx, builders) in lines { + let line_color = accent_colors.color_for_index(color_idx as u32); + + for builder in builders { + if let Ok(path) = builder.build() { + // we paint each color on it's own layer to stop overlapping lines + // of different colors changing the color of a line + window.paint_layer(bounds, |window| { + window.paint_path(path, line_color); + }); + } + } + } + }) + }, + ) + .w(graph_width) + .h_full() + } + + fn handle_graph_scroll( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + let line_height = window.line_height(); + let delta = event.delta.pixel_delta(line_height); + + let table_state = self.table_interaction_state.read(cx); + let current_offset = table_state.scroll_offset(); + + let viewport_height = table_state.scroll_handle.viewport().size.height; + + let commit_count = match self.graph_data.max_commit_count { + AllCommitCount::Loaded(count) => count, + AllCommitCount::NotLoaded => self.graph_data.commits.len(), + }; + let content_height = self.row_height * commit_count; + let max_vertical_scroll = (viewport_height - content_height).min(px(0.)); + + let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); + let new_offset = Point::new(current_offset.x, new_y); + + let max_lanes = self.graph_data.max_lanes.max(1); + let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; + let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.)); + + let new_horizontal_offset = + (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll); + + let vertical_changed = new_offset != current_offset; + let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset; + + if vertical_changed { + table_state.set_scroll_offset(new_offset); + } + + if horizontal_changed { + self.horizontal_scroll_offset = new_horizontal_offset; + } + + if vertical_changed || horizontal_changed { + cx.notify(); + } + } +} + +impl Render for GitGraph { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let description_width_fraction = 0.72; + let date_width_fraction = 0.12; + let author_width_fraction = 0.10; + let commit_width_fraction = 0.06; + + let commit_count = match self.graph_data.max_commit_count { + AllCommitCount::Loaded(count) => count, + AllCommitCount::NotLoaded => { + self.project.update(cx, |project, cx| { + if let Some(repository) = project.active_repository(cx) { + repository.update(cx, |repository, cx| { + // Start loading the graph data if we haven't started already + repository.graph_data( + self.log_source.clone(), + self.log_order, + 0..0, + cx, + ); + }) + } + }); + + self.graph_data.commits.len() + } + }; + + let content = if self.graph_data.commits.is_empty() { + let message = "No commits found"; + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new(message).color(Color::Muted)) + } else { + div() + .size_full() + .flex() + .flex_row() + .child( + div() + .w(self.graph_content_width()) + .h_full() + .flex() + .flex_col() + .child( + div() + .p_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(Label::new("Graph").color(Color::Muted)), + ) + .child( + div() + .id("graph-canvas") + .flex_1() + .overflow_hidden() + .child(self.render_graph(cx)) + .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)), + ), + ) + .child({ + let row_height = self.row_height; + let selected_entry_idx = self.selected_entry_idx; + let weak_self = cx.weak_entity(); + div().flex_1().size_full().child( + Table::new(4) + .interactable(&self.table_interaction_state) + .hide_row_borders() + .header(vec![ + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date").color(Color::Muted).into_any_element(), + Label::new("Author").color(Color::Muted).into_any_element(), + Label::new("Commit").color(Color::Muted).into_any_element(), + ]) + .column_widths( + [ + DefiniteLength::Fraction(description_width_fraction), + DefiniteLength::Fraction(date_width_fraction), + DefiniteLength::Fraction(author_width_fraction), + DefiniteLength::Fraction(commit_width_fraction), + ] + .to_vec(), + ) + .map_row(move |(index, row), _window, cx| { + let is_selected = selected_entry_idx == Some(index); + let weak = weak_self.clone(); + row.h(row_height) + .when(is_selected, |row| { + row.bg(cx.theme().colors().element_selected) + }) + .on_click(move |_, _, cx| { + weak.update(cx, |this, cx| { + this.select_entry(index, cx); + }) + .ok(); + }) + .into_any_element() + }) + .uniform_list( + "git-graph-commits", + commit_count, + cx.processor(Self::render_table_rows), + ), + ) + }) + .when(self.selected_entry_idx.is_some(), |this| { + this.child(self.render_commit_detail_panel(window, cx)) + }) + }; + + div() + .size_full() + .bg(cx.theme().colors().editor_background) + .key_context("GitGraph") + .track_focus(&self.focus_handle) + .child(content) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(Corner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + } +} + +impl EventEmitter for GitGraph {} + +impl Focusable for GitGraph { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for GitGraph { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Git Graph".into() + } + + fn show_toolbar(&self) -> bool { + false + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(*event) + } +} + +impl SerializableItem for GitGraph { + fn serialized_item_kind() -> &'static str { + "GitGraph" + } + + fn cleanup( + workspace_id: workspace::WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + workspace::delete_unloaded_items( + alive_items, + workspace_id, + "git_graphs", + &persistence::GIT_GRAPHS, + cx, + ) + } + + fn deserialize( + project: Entity, + _: WeakEntity, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + if persistence::GIT_GRAPHS + .get_git_graph(item_id, workspace_id) + .ok() + .is_some_and(|is_open| is_open) + { + let git_graph = cx.new(|cx| GitGraph::new(project, window, cx)); + Task::ready(Ok(git_graph)) + } else { + Task::ready(Err(anyhow::anyhow!("No git graph to deserialize"))) + } + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + Some(cx.background_spawn(async move { + persistence::GIT_GRAPHS + .save_git_graph(item_id, workspace_id, true) + .await + })) + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + event == &ItemEvent::UpdateTab + } +} + +mod persistence { + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + use workspace::WorkspaceDb; + + pub struct GitGraphsDb(ThreadSafeConnection); + + impl Domain for GitGraphsDb { + const NAME: &str = stringify!(GitGraphsDb); + + const MIGRATIONS: &[&str] = (&[sql!( + CREATE TABLE git_graphs ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + is_open INTEGER DEFAULT FALSE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]); + } + + db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]); + + impl GitGraphsDb { + query! { + pub async fn save_git_graph( + item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId, + is_open: bool + ) -> Result<()> { + INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open) + VALUES (?, ?, ?) + } + } + + query! { + pub fn get_git_graph( + item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId + ) -> Result { + SELECT is_open + FROM git_graphs + WHERE item_id = ? AND workspace_id = ? + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context, Result, bail}; + use collections::{HashMap, HashSet}; + use fs::FakeFs; + use git::Oid; + use git::repository::InitialGraphCommitData; + use gpui::TestAppContext; + use project::Project; + use rand::prelude::*; + use serde_json::json; + use settings::SettingsStore; + use smallvec::{SmallVec, smallvec}; + use std::path::Path; + use std::sync::Arc; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + /// Generates a random commit DAG suitable for testing git graph rendering. + /// + /// The commits are ordered newest-first (like git log output), so: + /// - Index 0 = most recent commit (HEAD) + /// - Last index = oldest commit (root, has no parents) + /// - Parents of commit at index I must have index > I + /// + /// When `adversarial` is true, generates complex topologies with many branches + /// and octopus merges. Otherwise generates more realistic linear histories + /// with occasional branches. + fn generate_random_commit_dag( + rng: &mut StdRng, + num_commits: usize, + adversarial: bool, + ) -> Vec> { + if num_commits == 0 { + return Vec::new(); + } + + let mut commits: Vec> = Vec::with_capacity(num_commits); + let oids: Vec = (0..num_commits).map(|_| Oid::random(rng)).collect(); + + for i in 0..num_commits { + let sha = oids[i]; + + let parents = if i == num_commits - 1 { + smallvec![] + } else { + generate_parents_from_oids(rng, &oids, i, num_commits, adversarial) + }; + + let ref_names = if i == 0 { + vec!["HEAD".into(), "main".into()] + } else if adversarial && rng.random_bool(0.1) { + vec![format!("branch-{}", i).into()] + } else { + Vec::new() + }; + + commits.push(Arc::new(InitialGraphCommitData { + sha, + parents, + ref_names, + })); + } + + commits + } + + fn generate_parents_from_oids( + rng: &mut StdRng, + oids: &[Oid], + current_idx: usize, + num_commits: usize, + adversarial: bool, + ) -> SmallVec<[Oid; 1]> { + let remaining = num_commits - current_idx - 1; + if remaining == 0 { + return smallvec![]; + } + + if adversarial { + let merge_chance = 0.4; + let octopus_chance = 0.15; + + if remaining >= 3 && rng.random_bool(octopus_chance) { + let num_parents = rng.random_range(3..=remaining.min(5)); + let mut parent_indices: Vec = (current_idx + 1..num_commits).collect(); + parent_indices.shuffle(rng); + parent_indices + .into_iter() + .take(num_parents) + .map(|idx| oids[idx]) + .collect() + } else if remaining >= 2 && rng.random_bool(merge_chance) { + let mut parent_indices: Vec = (current_idx + 1..num_commits).collect(); + parent_indices.shuffle(rng); + parent_indices + .into_iter() + .take(2) + .map(|idx| oids[idx]) + .collect() + } else { + let parent_idx = rng.random_range(current_idx + 1..num_commits); + smallvec![oids[parent_idx]] + } + } else { + let merge_chance = 0.15; + let skip_chance = 0.1; + + if remaining >= 2 && rng.random_bool(merge_chance) { + let first_parent = current_idx + 1; + let second_parent = rng.random_range(current_idx + 2..num_commits); + smallvec![oids[first_parent], oids[second_parent]] + } else if rng.random_bool(skip_chance) && remaining >= 2 { + let skip = rng.random_range(1..remaining.min(3)); + smallvec![oids[current_idx + 1 + skip]] + } else { + smallvec![oids[current_idx + 1]] + } + } + } + + fn build_oid_to_row_map(graph: &GraphData) -> HashMap { + graph + .commits + .iter() + .enumerate() + .map(|(idx, entry)| (entry.data.sha, idx)) + .collect() + } + + fn verify_commit_order( + graph: &GraphData, + commits: &[Arc], + ) -> Result<()> { + if graph.commits.len() != commits.len() { + bail!( + "Commit count mismatch: graph has {} commits, expected {}", + graph.commits.len(), + commits.len() + ); + } + + for (idx, (graph_commit, expected_commit)) in + graph.commits.iter().zip(commits.iter()).enumerate() + { + if graph_commit.data.sha != expected_commit.sha { + bail!( + "Commit order mismatch at index {}: graph has {:?}, expected {:?}", + idx, + graph_commit.data.sha, + expected_commit.sha + ); + } + } + + Ok(()) + } + + fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap) -> Result<()> { + for line in &graph.lines { + let child_row = *oid_to_row + .get(&line.child) + .context("Line references non-existent child commit")?; + + let parent_row = *oid_to_row + .get(&line.parent) + .context("Line references non-existent parent commit")?; + + if child_row >= parent_row { + bail!( + "child_row ({}) must be < parent_row ({})", + child_row, + parent_row + ); + } + + if line.full_interval.start != child_row { + bail!( + "full_interval.start ({}) != child_row ({})", + line.full_interval.start, + child_row + ); + } + + if line.full_interval.end != parent_row { + bail!( + "full_interval.end ({}) != parent_row ({})", + line.full_interval.end, + parent_row + ); + } + + if let Some(last_segment) = line.segments.last() { + let segment_end_row = match last_segment { + CommitLineSegment::Straight { to_row } => *to_row, + CommitLineSegment::Curve { on_row, .. } => *on_row, + }; + + if segment_end_row != line.full_interval.end { + bail!( + "last segment ends at row {} but full_interval.end is {}", + segment_end_row, + line.full_interval.end + ); + } + } + } + + Ok(()) + } + + fn verify_column_correctness( + graph: &GraphData, + oid_to_row: &HashMap, + ) -> Result<()> { + for line in &graph.lines { + let child_row = *oid_to_row + .get(&line.child) + .context("Line references non-existent child commit")?; + + let parent_row = *oid_to_row + .get(&line.parent) + .context("Line references non-existent parent commit")?; + + let child_lane = graph.commits[child_row].lane; + if line.child_column != child_lane { + bail!( + "child_column ({}) != child's lane ({})", + line.child_column, + child_lane + ); + } + + let mut current_column = line.child_column; + for segment in &line.segments { + if let CommitLineSegment::Curve { to_column, .. } = segment { + current_column = *to_column; + } + } + + let parent_lane = graph.commits[parent_row].lane; + if current_column != parent_lane { + bail!( + "ending column ({}) != parent's lane ({})", + current_column, + parent_lane + ); + } + } + + Ok(()) + } + + fn verify_segment_continuity(graph: &GraphData) -> Result<()> { + for line in &graph.lines { + if line.segments.is_empty() { + bail!("Line has no segments"); + } + + let mut current_row = line.full_interval.start; + + for (idx, segment) in line.segments.iter().enumerate() { + let segment_end_row = match segment { + CommitLineSegment::Straight { to_row } => *to_row, + CommitLineSegment::Curve { on_row, .. } => *on_row, + }; + + if segment_end_row < current_row { + bail!( + "segment {} ends at row {} which is before current row {}", + idx, + segment_end_row, + current_row + ); + } + + current_row = segment_end_row; + } + } + + Ok(()) + } + + fn verify_line_overlaps(graph: &GraphData) -> Result<()> { + for line in &graph.lines { + let child_row = line.full_interval.start; + + let mut current_column = line.child_column; + let mut current_row = child_row; + + for segment in &line.segments { + match segment { + CommitLineSegment::Straight { to_row } => { + for row in (current_row + 1)..*to_row { + if row < graph.commits.len() { + let commit_at_row = &graph.commits[row]; + if commit_at_row.lane == current_column { + bail!( + "straight segment from row {} to {} in column {} passes through commit {:?} at row {}", + current_row, + to_row, + current_column, + commit_at_row.data.sha, + row + ); + } + } + } + current_row = *to_row; + } + CommitLineSegment::Curve { + to_column, on_row, .. + } => { + current_column = *to_column; + current_row = *on_row; + } + } + } + } + + Ok(()) + } + + fn verify_coverage(graph: &GraphData) -> Result<()> { + let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default(); + for entry in &graph.commits { + for parent in &entry.data.parents { + expected_edges.insert((entry.data.sha, *parent)); + } + } + + let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default(); + for line in &graph.lines { + let edge = (line.child, line.parent); + + if !found_edges.insert(edge) { + bail!( + "Duplicate line found for edge {:?} -> {:?}", + line.child, + line.parent + ); + } + + if !expected_edges.contains(&edge) { + bail!( + "Orphan line found: {:?} -> {:?} is not in the commit graph", + line.child, + line.parent + ); + } + } + + for (child, parent) in &expected_edges { + if !found_edges.contains(&(*child, *parent)) { + bail!("Missing line for edge {:?} -> {:?}", child, parent); + } + } + + assert_eq!( + expected_edges.symmetric_difference(&found_edges).count(), + 0, + "The symmetric difference should be zero" + ); + + Ok(()) + } + + fn verify_merge_line_optimality( + graph: &GraphData, + oid_to_row: &HashMap, + ) -> Result<()> { + for line in &graph.lines { + let first_segment = line.segments.first(); + let is_merge_line = matches!( + first_segment, + Some(CommitLineSegment::Curve { + curve_kind: CurveKind::Merge, + .. + }) + ); + + if !is_merge_line { + continue; + } + + let child_row = *oid_to_row + .get(&line.child) + .context("Line references non-existent child commit")?; + + let parent_row = *oid_to_row + .get(&line.parent) + .context("Line references non-existent parent commit")?; + + let parent_lane = graph.commits[parent_row].lane; + + let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else { + continue; + }; + + let curves_directly_to_parent = *to_column == parent_lane; + + if !curves_directly_to_parent { + continue; + } + + let curve_row = child_row + 1; + let has_commits_in_path = graph.commits[curve_row..parent_row] + .iter() + .any(|c| c.lane == parent_lane); + + if has_commits_in_path { + bail!( + "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}", + line.child, + line.parent, + parent_lane, + curve_row, + parent_row + ); + } + + let curve_ends_at_parent = curve_row == parent_row; + + if curve_ends_at_parent { + if line.segments.len() != 1 { + bail!( + "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]", + line.child, + line.parent, + line.segments.len() + ); + } + } else { + if line.segments.len() != 2 { + bail!( + "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]", + line.child, + line.parent, + line.segments.len() + ); + } + + let is_straight_segment = matches!( + line.segments.get(1), + Some(CommitLineSegment::Straight { .. }) + ); + + if !is_straight_segment { + bail!( + "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment", + line.child, + line.parent + ); + } + } + } + + Ok(()) + } + + fn verify_all_invariants( + graph: &GraphData, + commits: &[Arc], + ) -> Result<()> { + let oid_to_row = build_oid_to_row_map(graph); + + verify_commit_order(graph, commits).context("commit order")?; + verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?; + verify_column_correctness(graph, &oid_to_row).context("column correctness")?; + verify_segment_continuity(graph).context("segment continuity")?; + verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?; + verify_coverage(graph).context("coverage")?; + verify_line_overlaps(graph).context("line overlaps")?; + Ok(()) + } + + #[test] + fn test_git_graph_merge_commits() { + let mut rng = StdRng::seed_from_u64(42); + + let oid1 = Oid::random(&mut rng); + let oid2 = Oid::random(&mut rng); + let oid3 = Oid::random(&mut rng); + let oid4 = Oid::random(&mut rng); + + let commits = vec![ + Arc::new(InitialGraphCommitData { + sha: oid1, + parents: smallvec![oid2, oid3], + ref_names: vec!["HEAD".into()], + }), + Arc::new(InitialGraphCommitData { + sha: oid2, + parents: smallvec![oid4], + ref_names: vec![], + }), + Arc::new(InitialGraphCommitData { + sha: oid3, + parents: smallvec![oid4], + ref_names: vec![], + }), + Arc::new(InitialGraphCommitData { + sha: oid4, + parents: smallvec![], + ref_names: vec![], + }), + ]; + + let mut graph_data = GraphData::new(8); + graph_data.add_commits(&commits); + + if let Err(error) = verify_all_invariants(&graph_data, &commits) { + panic!("Graph invariant violation for merge commits:\n{}", error); + } + } + + #[test] + fn test_git_graph_linear_commits() { + let mut rng = StdRng::seed_from_u64(42); + + let oid1 = Oid::random(&mut rng); + let oid2 = Oid::random(&mut rng); + let oid3 = Oid::random(&mut rng); + + let commits = vec![ + Arc::new(InitialGraphCommitData { + sha: oid1, + parents: smallvec![oid2], + ref_names: vec!["HEAD".into()], + }), + Arc::new(InitialGraphCommitData { + sha: oid2, + parents: smallvec![oid3], + ref_names: vec![], + }), + Arc::new(InitialGraphCommitData { + sha: oid3, + parents: smallvec![], + ref_names: vec![], + }), + ]; + + let mut graph_data = GraphData::new(8); + graph_data.add_commits(&commits); + + if let Err(error) = verify_all_invariants(&graph_data, &commits) { + panic!("Graph invariant violation for linear commits:\n{}", error); + } + } + + #[test] + fn test_git_graph_random_commits() { + for seed in 0..100 { + let mut rng = StdRng::seed_from_u64(seed); + + let adversarial = rng.random_bool(0.2); + let num_commits = if adversarial { + rng.random_range(10..100) + } else { + rng.random_range(5..50) + }; + + let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial); + + assert_eq!( + num_commits, + commits.len(), + "seed={}: Generate random commit dag didn't generate the correct amount of commits", + seed + ); + + let mut graph_data = GraphData::new(8); + graph_data.add_commits(&commits); + + if let Err(error) = verify_all_invariants(&graph_data, &commits) { + panic!( + "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}", + seed, adversarial, num_commits, error + ); + } + } + } + + // The full integration test has less iterations because it's significantly slower + // than the random commit test + #[gpui::test(iterations = 5)] + async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + + let adversarial = rng.random_bool(0.2); + let num_commits = if adversarial { + rng.random_range(10..100) + } else { + rng.random_range(5..50) + }; + + let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + fs.set_graph_commits(Path::new("/project/.git"), commits.clone()); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + repository.update(cx, |repo, cx| { + repo.graph_data( + crate::LogSource::default(), + crate::LogOrder::default(), + 0..usize::MAX, + cx, + ); + }); + cx.run_until_parked(); + + let graph_commits: Vec> = repository.update(cx, |repo, cx| { + repo.graph_data( + crate::LogSource::default(), + crate::LogOrder::default(), + 0..usize::MAX, + cx, + ) + .to_vec() + }); + + let mut graph_data = GraphData::new(8); + graph_data.add_commits(&graph_commits); + + if let Err(error) = verify_all_invariants(&graph_data, &commits) { + panic!( + "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}", + adversarial, num_commits, error + ); + } + } +} diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index e9186a8ad6f4d9acfb39ab940e277a742c02449e..2f77ac31a0f55ae2e6e219d7275bab54cd99e2a7 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -30,8 +30,9 @@ use git::{ parse_git_remote_url, repository::{ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions, - GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, - ResetMode, UpstreamTrackingStatus, Worktree as GitWorktree, + GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder, + LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, status::{ @@ -252,6 +253,12 @@ pub struct MergeDetails { pub heads: Vec>, } +#[derive(Clone)] +pub enum CommitDataState { + Loading, + Loaded(Arc), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RepositorySnapshot { pub id: RepositoryId, @@ -275,6 +282,17 @@ pub struct JobInfo { pub message: SharedString, } +struct GraphCommitDataHandler { + _task: Task<()>, + commit_data_request: smol::channel::Sender, +} + +enum GraphCommitHandlerState { + Starting, + Open(GraphCommitDataHandler), + Closed, +} + pub struct Repository { this: WeakEntity, snapshot: RepositorySnapshot, @@ -290,6 +308,15 @@ pub struct Repository { askpass_delegates: Arc>>, latest_askpass_id: u64, repository_state: Shared>>, + pub initial_graph_data: HashMap< + (LogOrder, LogSource), + ( + Task>, + Vec>, + ), + >, + graph_commit_data_handler: GraphCommitHandlerState, + commit_data: HashMap, } impl std::ops::Deref for Repository { @@ -367,6 +394,7 @@ pub enum RepositoryEvent { BranchChanged, StashEntriesChanged, PendingOpsChanged { pending_ops: SumTree }, + GitGraphCountUpdated((LogOrder, LogSource), usize), } #[derive(Clone, Debug)] @@ -375,6 +403,7 @@ pub struct JobsUpdated; #[derive(Debug)] pub enum GitStoreEvent { ActiveRepositoryChanged(Option), + /// Bool is true when the repository that's updated is the active repository RepositoryUpdated(RepositoryId, RepositoryEvent, bool), RepositoryAdded, RepositoryRemoved(RepositoryId), @@ -3586,6 +3615,14 @@ impl Repository { }) .shared(); + cx.subscribe_self(|this, event: &RepositoryEvent, _| match event { + RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => { + this.initial_graph_data.clear(); + } + _ => {} + }) + .detach(); + Repository { this: cx.weak_entity(), git_store, @@ -3599,6 +3636,9 @@ impl Repository { job_sender, job_id: 0, active_jobs: Default::default(), + initial_graph_data: Default::default(), + commit_data: Default::default(), + graph_commit_data_handler: GraphCommitHandlerState::Closed, } } @@ -3628,6 +3668,9 @@ impl Repository { latest_askpass_id: 0, active_jobs: Default::default(), job_id: 0, + initial_graph_data: Default::default(), + commit_data: Default::default(), + graph_commit_data_handler: GraphCommitHandlerState::Closed, } } @@ -4191,6 +4234,208 @@ impl Repository { }) } + pub fn graph_data( + &mut self, + log_source: LogSource, + log_order: LogOrder, + range: Range, + cx: &mut Context, + ) -> &[Arc] { + let initial_commit_data = &self + .initial_graph_data + .entry((log_order, log_source.clone())) + .or_insert_with(|| { + let state = self.repository_state.clone(); + let log_source = log_source.clone(); + ( + cx.spawn(async move |repository, cx| { + let state = state.await; + match state { + Ok(RepositoryState::Local(LocalRepositoryState { + backend, .. + })) => { + Self::local_git_graph_data( + repository, backend, log_source, log_order, cx, + ) + .await + } + Ok(RepositoryState::Remote(_)) => { + Err("Git graph is not supported for collab yet".into()) + } + Err(e) => Err(SharedString::from(e)), + } + }), + vec![], + ) + }) + .1; + + let max_start = initial_commit_data.len().saturating_sub(1); + let max_end = initial_commit_data.len(); + &initial_commit_data[range.start.min(max_start)..range.end.min(max_end)] + } + + async fn local_git_graph_data( + this: WeakEntity, + backend: Arc, + log_source: LogSource, + log_order: LogOrder, + cx: &mut AsyncApp, + ) -> Result<(), SharedString> { + let (request_tx, request_rx) = + smol::channel::unbounded::>>(); + + let task = cx.background_executor().spawn({ + let log_source = log_source.clone(); + async move { + backend + .initial_graph_data(log_source, log_order, request_tx) + .await + .map_err(|err| SharedString::from(err.to_string())) + } + }); + + let graph_data_key = (log_order, log_source.clone()); + + while let Ok(initial_graph_commit_data) = request_rx.recv().await { + this.update(cx, |repository, cx| { + let graph_data = repository + .initial_graph_data + .get_mut(&graph_data_key) + .map(|(_, graph_data)| graph_data); + debug_assert!( + graph_data.is_some(), + "This task should be dropped if data doesn't exist" + ); + + if let Some(graph_data) = graph_data { + graph_data.extend(initial_graph_commit_data); + cx.emit(RepositoryEvent::GitGraphCountUpdated( + graph_data_key.clone(), + graph_data.len(), + )); + } + }) + .ok(); + } + + task.await?; + + Ok(()) + } + + pub fn fetch_commit_data(&mut self, sha: Oid, cx: &mut Context) -> &CommitDataState { + if !self.commit_data.contains_key(&sha) { + match &self.graph_commit_data_handler { + GraphCommitHandlerState::Open(handler) => { + if handler.commit_data_request.try_send(sha).is_ok() { + let old_value = self.commit_data.insert(sha, CommitDataState::Loading); + debug_assert!(old_value.is_none(), "We should never overwrite commit data"); + } + } + GraphCommitHandlerState::Closed => { + self.open_graph_commit_data_handler(cx); + } + GraphCommitHandlerState::Starting => {} + } + } + + self.commit_data + .get(&sha) + .unwrap_or(&CommitDataState::Loading) + } + + fn open_graph_commit_data_handler(&mut self, cx: &mut Context) { + self.graph_commit_data_handler = GraphCommitHandlerState::Starting; + + let state = self.repository_state.clone(); + let (result_tx, result_rx) = smol::channel::bounded::<(Oid, GraphCommitData)>(64); + let (request_tx, request_rx) = smol::channel::unbounded::(); + + let foreground_task = cx.spawn(async move |this, cx| { + while let Ok((sha, commit_data)) = result_rx.recv().await { + let result = this.update(cx, |this, cx| { + let old_value = this + .commit_data + .insert(sha, CommitDataState::Loaded(Arc::new(commit_data))); + debug_assert!( + !matches!(old_value, Some(CommitDataState::Loaded(_))), + "We should never overwrite commit data" + ); + + cx.notify(); + }); + if result.is_err() { + break; + } + } + + this.update(cx, |this, _cx| { + this.graph_commit_data_handler = GraphCommitHandlerState::Closed; + }) + .ok(); + }); + + let request_tx_for_handler = request_tx; + let background_executor = cx.background_executor().clone(); + + cx.background_spawn(async move { + let backend = match state.await { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => backend, + Ok(RepositoryState::Remote(_)) => { + log::error!("commit_data_reader not supported for remote repositories"); + return; + } + Err(error) => { + log::error!("failed to get repository state: {error}"); + return; + } + }; + + let reader = match backend.commit_data_reader() { + Ok(reader) => reader, + Err(error) => { + log::error!("failed to create commit data reader: {error:?}"); + return; + } + }; + + loop { + let timeout = background_executor.timer(std::time::Duration::from_secs(10)); + + futures::select_biased! { + sha = futures::FutureExt::fuse(request_rx.recv()) => { + let Ok(sha) = sha else { + break; + }; + + match reader.read(sha).await { + Ok(commit_data) => { + if result_tx.send((sha, commit_data)).await.is_err() { + break; + } + } + Err(error) => { + log::error!("failed to read commit data for {sha}: {error:?}"); + } + } + } + _ = futures::FutureExt::fuse(timeout) => { + break; + } + } + } + + drop(result_tx); + }) + .detach(); + + self.graph_commit_data_handler = GraphCommitHandlerState::Open(GraphCommitDataHandler { + _task: foreground_task, + commit_data_request: request_tx_for_handler, + }); + } + fn buffer_store(&self, cx: &App) -> Option> { Some(self.git_store.upgrade()?.read(cx).buffer_store.clone()) } diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 8362ada27d67c056c76266c3f683e6aac7f3f183..e09e3184079d0f1f37c520b830faab08bb27bd1d 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -723,6 +723,7 @@ impl TableWidths { #[derive(RegisterComponent, IntoElement)] pub struct Table { striped: bool, + show_row_borders: bool, width: Option, headers: Option>, rows: TableContents, @@ -741,6 +742,7 @@ impl Table { Self { cols, striped: false, + show_row_borders: true, width: None, headers: None, rows: TableContents::Vec(Vec::new()), @@ -804,6 +806,12 @@ impl Table { self } + /// Hides the border lines between rows + pub fn hide_row_borders(mut self) -> Self { + self.show_row_borders = false; + self + } + /// Sets the width of the table. /// Will enable horizontal scrolling if [`Self::interactable`] is also called. pub fn width(mut self, width: impl Into) -> Self { @@ -941,7 +949,7 @@ pub fn render_table_row( .size_full() .when_some(bg, |row, bg| row.bg(bg)) .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6))) - .when(!is_striped, |row| { + .when(!is_striped && table_context.show_row_borders, |row| { row.border_b_1() .border_color(transparent_black()) .when(!is_last, |row| row.border_color(cx.theme().colors().border)) @@ -1046,6 +1054,7 @@ pub fn render_table_header( #[derive(Clone)] pub struct TableRenderContext { pub striped: bool, + pub show_row_borders: bool, pub total_row_count: usize, pub column_widths: Option>, pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, @@ -1056,6 +1065,7 @@ impl TableRenderContext { fn new(table: &Table, cx: &App) -> Self { Self { striped: table.striped, + show_row_borders: table.show_row_borders, total_row_count: table.rows.len(), column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 7eb6f0b940f343cd5fd876973b9e9a3a36e9bf1c..9856b96b34b532357b11f2779a901c31c25e9a65 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -110,6 +110,7 @@ file_finder.workspace = true fs.workspace = true futures.workspace = true git.workspace = true +git_graph.workspace = true git_hosting_providers.workspace = true git_ui.workspace = true go_to_line.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 62f585ec1a7216a79df51b7ac75a155c5dbaefdb..6ab3e51f6eba8d4b639aef4bc32a58e0266a897b 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -681,6 +681,7 @@ fn main() { notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); git_ui::init(cx); + git_graph::init(cx); feedback::init(cx); markdown_preview::init(cx); svg_preview::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7395c31a902468b7133b1540a321b6f0d468c682..942b79f36bc658b274b49326d7c1dc930a3e546b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4829,6 +4829,7 @@ mod tests { "feedback", "file_finder", "git", + "git_graph", "git_onboarding", "git_panel", "git_picker", @@ -5025,6 +5026,7 @@ mod tests { language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); + git_graph::init(cx); web_search_providers::init(app_state.client.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); project::AgentRegistryStore::init_global(cx);