Show branch starts in commit history

This commit is contained in:
Georg Bauer
2026-07-31 21:30:29 +02:00
parent a3962449a2
commit 16ecf68bf8
6 changed files with 134 additions and 22 deletions

View File

@@ -214,6 +214,9 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Commit graph lanes align with their rows while scrolling; branch-out and - [ ] Commit graph lanes align with their rows while scrolling; branch-out and
merge-back connections use smooth, rounded curves that meet the correct merge-back connections use smooth, rounded curves that meet the correct
lane and commit node without angular horizontal bars or overlaps. lane and commit node without angular horizontal bars or overlaps.
- [ ] In **All** history, branch names use the accent color both at branch tips
and at the first commit made on a pull-request branch, including a merged
branch whose tip was deleted.
- [ ] Open a commit and verify Changed Files paths and statuses. - [ ] Open a commit and verify Changed Files paths and statuses.
- [ ] Open a changed file and verify the diff title, old/new line numbers, - [ ] Open a changed file and verify the diff title, old/new line numbers,
monospaced text, addition/removal/hunk colors, vertical scrolling, and monospaced text, addition/removal/hunk colors, vertical scrolling, and

View File

@@ -180,6 +180,7 @@ pub struct CommitRow {
pub sha: String, pub sha: String,
pub title: String, pub title: String,
pub detail: String, pub detail: String,
pub branch_label: Option<String>,
pub top_lanes: Vec<u32>, pub top_lanes: Vec<u32>,
pub bottom_lanes: Vec<u32>, pub bottom_lanes: Vec<u32>,
pub node_lane: Option<u32>, pub node_lane: Option<u32>,
@@ -516,6 +517,12 @@ pub fn commit_page(
.and_then(|commit| commit.author.as_ref()) .and_then(|commit| commit.author.as_ref())
.and_then(|author| author.date.as_deref()) .and_then(|author| author.date.as_deref())
.or(commit.created.as_deref()); .or(commit.created.as_deref());
let mut branch_labels = row.refs.clone();
for label in &row.branch_starts {
if !branch_labels.contains(label) {
branch_labels.push(label.clone());
}
}
Some(CommitRow { Some(CommitRow {
sha: sha.into(), sha: sha.into(),
title: details title: details
@@ -523,16 +530,12 @@ pub fn commit_page(
.map(|message| message.lines().next().unwrap_or(message)) .map(|message| message.lines().next().unwrap_or(message))
.unwrap_or("Commit") .unwrap_or("Commit")
.into(), .into(),
detail: if row.refs.is_empty() { detail: if !branch_labels.is_empty() {
format!("{author} · {}\n{}", compact_date(date), short_sha(sha)) format!("{author} · {} · {}", compact_date(date), short_sha(sha))
} else { } else {
format!( format!("{author} · {}\n{}", compact_date(date), short_sha(sha))
"{}\n{author} · {} · {}",
row.refs.join(" · "),
compact_date(date),
short_sha(sha)
)
}, },
branch_label: (!branch_labels.is_empty()).then(|| branch_labels.join(" · ")),
top_lanes: indices(&row.top_lanes), top_lanes: indices(&row.top_lanes),
bottom_lanes: indices(&row.bottom_lanes), bottom_lanes: indices(&row.bottom_lanes),
node_lane: row.node_lane.map(|lane| lane as u32), node_lane: row.node_lane.map(|lane| lane as u32),

View File

@@ -1,5 +1,3 @@
use std::collections::HashMap;
use crate::models; use crate::models;
pub const DEFAULT_PAGE_SIZE: i32 = 30; pub const DEFAULT_PAGE_SIZE: i32 = 30;
@@ -159,6 +157,7 @@ pub struct HistoryCommit {
pub top_connections: Vec<usize>, pub top_connections: Vec<usize>,
pub bottom_connections: Vec<usize>, pub bottom_connections: Vec<usize>,
pub refs: Vec<String>, pub refs: Vec<String>,
pub branch_starts: Vec<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -168,8 +167,6 @@ pub struct HomeData {
pub next_page: Option<i32>, pub next_page: Option<i32>,
} }
pub type PullRefs = HashMap<String, Vec<String>>;
pub fn api_date(timestamp: i64) -> String { pub fn api_date(timestamp: i64) -> String {
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400)); let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
format!("{year:04}-{month:02}-{day:02}T00:00:00Z") format!("{year:04}-{month:02}-{day:02}T00:00:00Z")

View File

@@ -7,7 +7,7 @@ use tokio::task::JoinSet;
use crate::{ use crate::{
Client, Error, Result, Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, PullRefs, RepositoryId}, domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
models, models,
}; };
use gitea_openapi::apis; use gitea_openapi::apis;
@@ -147,6 +147,7 @@ impl Client {
top_connections: Vec::new(), top_connections: Vec::new(),
bottom_connections: Vec::new(), bottom_connections: Vec::new(),
refs: Vec::new(), refs: Vec::new(),
branch_starts: Vec::new(),
}) })
.collect(), .collect(),
}) })
@@ -219,8 +220,8 @@ impl Client {
.map_err(Error::generated) .map_err(Error::generated)
} }
async fn pull_refs(&self, repository: &RepositoryId) -> Result<PullRefs> { async fn pull_refs(&self, repository: &RepositoryId) -> Result<PullMetadata> {
let mut refs: PullRefs = HashMap::new(); let mut refs = PullMetadata::default();
for page in 1.. { for page in 1.. {
let batch = self.repository_pulls(repository, "all", page, 100).await?; let batch = self.repository_pulls(repository, "all", page, 100).await?;
for pull in batch.items { for pull in batch.items {
@@ -230,7 +231,17 @@ impl Client {
continue; continue;
}; };
if !label.is_empty() { if !label.is_empty() {
refs.entry(sha).or_default().push(label); refs.tips
.entry(sha.clone())
.or_default()
.push(label.clone());
if let Some(base) = pull.merge_base.filter(|base| !base.is_empty()) {
refs.starts.push(PullBranch {
head: sha,
base,
label,
});
}
} }
} }
if !batch.has_more { if !batch.has_more {
@@ -281,13 +292,25 @@ impl Client {
} }
} }
#[derive(Default)]
struct PullMetadata {
tips: HashMap<String, Vec<String>>,
starts: Vec<PullBranch>,
}
struct PullBranch {
head: String,
base: String,
label: String,
}
fn build_graph( fn build_graph(
histories: Vec<(usize, String, Vec<models::Commit>)>, histories: Vec<(usize, String, Vec<models::Commit>)>,
mut extra_refs: PullRefs, extra_refs: PullMetadata,
) -> Vec<HistoryCommit> { ) -> Vec<HistoryCommit> {
let mut commits = HashMap::new(); let mut commits = HashMap::new();
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new(); let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
let mut refs: PullRefs = HashMap::new(); let mut refs: HashMap<String, Vec<String>> = HashMap::new();
for (branch_index, branch, history) in histories { for (branch_index, branch, history) in histories {
if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) { if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) {
refs.entry(sha).or_default().push(branch); refs.entry(sha).or_default().push(branch);
@@ -303,7 +326,7 @@ fn build_graph(
commits.entry(sha).or_insert(commit); commits.entry(sha).or_insert(commit);
} }
} }
for (sha, labels) in extra_refs.drain() { for (sha, labels) in extra_refs.tips {
let row = refs.entry(sha).or_default(); let row = refs.entry(sha).or_default();
for label in labels { for label in labels {
if !row.contains(&label) { if !row.contains(&label) {
@@ -311,6 +334,7 @@ fn build_graph(
} }
} }
} }
let mut branch_starts = branch_starts(&commits, extra_refs.starts);
let mut children = HashMap::new(); let mut children = HashMap::new();
for sha in commits.keys() { for sha in commits.keys() {
@@ -350,12 +374,46 @@ fn build_graph(
top_connections: Vec::new(), top_connections: Vec::new(),
bottom_connections: Vec::new(), bottom_connections: Vec::new(),
refs: refs.remove(&sha).unwrap_or_default(), refs: refs.remove(&sha).unwrap_or_default(),
branch_starts: branch_starts.remove(&sha).unwrap_or_default(),
}); });
} }
layout_graph(&mut ordered); layout_graph(&mut ordered);
ordered ordered
} }
fn branch_starts(
commits: &HashMap<String, models::Commit>,
branches: Vec<PullBranch>,
) -> HashMap<String, Vec<String>> {
let mut starts: HashMap<String, Vec<String>> = HashMap::new();
for branch in branches {
let Some(sha) = first_commit_after(commits, &branch.head, &branch.base) else {
continue;
};
let labels = starts.entry(sha).or_default();
if !labels.contains(&branch.label) {
labels.push(branch.label);
}
}
starts
}
fn first_commit_after(
commits: &HashMap<String, models::Commit>,
head: &str,
base: &str,
) -> Option<String> {
let mut sha = head;
for _ in 0..commits.len() {
let parent = commit_parents(commits.get(sha)?).next()?;
if parent == base {
return Some(sha.into());
}
sha = parent;
}
None
}
fn layout_graph(commits: &mut [HistoryCommit]) { fn layout_graph(commits: &mut [HistoryCommit]) {
let mut lanes: Vec<Option<String>> = Vec::new(); let mut lanes: Vec<Option<String>> = Vec::new();
for row in commits { for row in commits {
@@ -476,7 +534,7 @@ mod tests {
vec![commit("right", &["base"]), commit("base", &[])], vec![commit("right", &["base"]), commit("base", &[])],
), ),
], ],
HashMap::new(), PullMetadata::default(),
); );
assert_eq!(rows.len(), 4); assert_eq!(rows.len(), 4);
assert!(rows.iter().any(|row| row.refs == ["main"])); assert!(rows.iter().any(|row| row.refs == ["main"]));
@@ -499,4 +557,41 @@ mod tests {
assert_eq!(base.node_lane, Some(0)); assert_eq!(base.node_lane, Some(0));
assert_eq!(base.top_connections, [1]); assert_eq!(base.top_connections, [1]);
} }
#[test]
fn labels_first_commit_of_pull_branch() {
let rows = build_graph(
vec![(
0,
"main".into(),
vec![
commit("merge", &["main", "head"]),
commit("head", &["first"]),
commit("first", &["base"]),
commit("main", &["base"]),
commit("base", &[]),
],
)],
PullMetadata {
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
starts: vec![PullBranch {
head: "head".into(),
base: "base".into(),
label: "feature".into(),
}],
},
);
let first = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("first"))
.unwrap();
assert_eq!(first.branch_starts, ["feature"]);
assert!(
rows.iter()
.find(|row| row.commit.sha.as_deref() == Some("head"))
.unwrap()
.refs
.contains(&"feature".into())
);
}
} }

View File

@@ -1509,6 +1509,7 @@ public struct CommitRow: Equatable, Hashable {
public var sha: String public var sha: String
public var title: String public var title: String
public var detail: String public var detail: String
public var branchLabel: String?
public var topLanes: [UInt32] public var topLanes: [UInt32]
public var bottomLanes: [UInt32] public var bottomLanes: [UInt32]
public var nodeLane: UInt32? public var nodeLane: UInt32?
@@ -1517,10 +1518,11 @@ public struct CommitRow: Equatable, Hashable {
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(sha: String, title: String, detail: String, topLanes: [UInt32], bottomLanes: [UInt32], nodeLane: UInt32?, topConnections: [UInt32], bottomConnections: [UInt32]) { public init(sha: String, title: String, detail: String, branchLabel: String?, topLanes: [UInt32], bottomLanes: [UInt32], nodeLane: UInt32?, topConnections: [UInt32], bottomConnections: [UInt32]) {
self.sha = sha self.sha = sha
self.title = title self.title = title
self.detail = detail self.detail = detail
self.branchLabel = branchLabel
self.topLanes = topLanes self.topLanes = topLanes
self.bottomLanes = bottomLanes self.bottomLanes = bottomLanes
self.nodeLane = nodeLane self.nodeLane = nodeLane
@@ -1547,6 +1549,7 @@ public struct FfiConverterTypeCommitRow: FfiConverterRustBuffer {
sha: FfiConverterString.read(from: &buf), sha: FfiConverterString.read(from: &buf),
title: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf),
detail: FfiConverterString.read(from: &buf), detail: FfiConverterString.read(from: &buf),
branchLabel: FfiConverterOptionString.read(from: &buf),
topLanes: FfiConverterSequenceUInt32.read(from: &buf), topLanes: FfiConverterSequenceUInt32.read(from: &buf),
bottomLanes: FfiConverterSequenceUInt32.read(from: &buf), bottomLanes: FfiConverterSequenceUInt32.read(from: &buf),
nodeLane: FfiConverterOptionUInt32.read(from: &buf), nodeLane: FfiConverterOptionUInt32.read(from: &buf),
@@ -1559,6 +1562,7 @@ public struct FfiConverterTypeCommitRow: FfiConverterRustBuffer {
FfiConverterString.write(value.sha, into: &buf) FfiConverterString.write(value.sha, into: &buf)
FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.detail, into: &buf) FfiConverterString.write(value.detail, into: &buf)
FfiConverterOptionString.write(value.branchLabel, into: &buf)
FfiConverterSequenceUInt32.write(value.topLanes, into: &buf) FfiConverterSequenceUInt32.write(value.topLanes, into: &buf)
FfiConverterSequenceUInt32.write(value.bottomLanes, into: &buf) FfiConverterSequenceUInt32.write(value.bottomLanes, into: &buf)
FfiConverterOptionUInt32.write(value.nodeLane, into: &buf) FfiConverterOptionUInt32.write(value.nodeLane, into: &buf)

View File

@@ -1309,7 +1309,17 @@ final class CommitCell: UITableViewCell {
? 0 ? 0
: CGFloat(laneCount) * Self.laneSpacing + 10 : CGFloat(laneCount) * Self.laneSpacing + 10
content.text = row.title content.text = row.title
content.secondaryText = row.detail if let branch = row.branchLabel {
let text = NSMutableAttributedString(string: "\(branch)\n\(row.detail)")
text.addAttribute(
.foregroundColor,
value: UIColor.tintColor,
range: NSRange(location: 0, length: (branch as NSString).length)
)
content.secondaryAttributedText = text
} else {
content.secondaryText = row.detail
}
content.secondaryTextProperties.numberOfLines = 2 content.secondaryTextProperties.numberOfLines = 2
contentConfiguration = content contentConfiguration = content
setNeedsDisplay() setNeedsDisplay()