diff --git a/TESTING.md b/TESTING.md index 0df258d..4737278 100644 --- a/TESTING.md +++ b/TESTING.md @@ -214,6 +214,9 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Commit graph lanes align with their rows while scrolling; branch-out and merge-back connections use smooth, rounded curves that meet the correct 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 changed file and verify the diff title, old/new line numbers, monospaced text, addition/removal/hunk colors, vertical scrolling, and diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 214c602..c2144b0 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -180,6 +180,7 @@ pub struct CommitRow { pub sha: String, pub title: String, pub detail: String, + pub branch_label: Option, pub top_lanes: Vec, pub bottom_lanes: Vec, pub node_lane: Option, @@ -516,6 +517,12 @@ pub fn commit_page( .and_then(|commit| commit.author.as_ref()) .and_then(|author| author.date.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 { sha: sha.into(), title: details @@ -523,16 +530,12 @@ pub fn commit_page( .map(|message| message.lines().next().unwrap_or(message)) .unwrap_or("Commit") .into(), - detail: if row.refs.is_empty() { - format!("{author} · {}\n{}", compact_date(date), short_sha(sha)) + detail: if !branch_labels.is_empty() { + format!("{author} · {} · {}", compact_date(date), short_sha(sha)) } else { - format!( - "{}\n{author} · {} · {}", - row.refs.join(" · "), - compact_date(date), - short_sha(sha) - ) + format!("{author} · {}\n{}", compact_date(date), short_sha(sha)) }, + branch_label: (!branch_labels.is_empty()).then(|| branch_labels.join(" · ")), top_lanes: indices(&row.top_lanes), bottom_lanes: indices(&row.bottom_lanes), node_lane: row.node_lane.map(|lane| lane as u32), diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index 3c16d74..1b86c9d 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use crate::models; pub const DEFAULT_PAGE_SIZE: i32 = 30; @@ -159,6 +157,7 @@ pub struct HistoryCommit { pub top_connections: Vec, pub bottom_connections: Vec, pub refs: Vec, + pub branch_starts: Vec, } #[derive(Clone, Debug)] @@ -168,8 +167,6 @@ pub struct HomeData { pub next_page: Option, } -pub type PullRefs = HashMap>; - pub fn api_date(timestamp: i64) -> String { let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400)); format!("{year:04}-{month:02}-{day:02}T00:00:00Z") diff --git a/crates/gitea/src/repositories.rs b/crates/gitea/src/repositories.rs index 70c1ab0..b2579e6 100644 --- a/crates/gitea/src/repositories.rs +++ b/crates/gitea/src/repositories.rs @@ -7,7 +7,7 @@ use tokio::task::JoinSet; use crate::{ Client, Error, Result, - domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, PullRefs, RepositoryId}, + domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId}, models, }; use gitea_openapi::apis; @@ -147,6 +147,7 @@ impl Client { top_connections: Vec::new(), bottom_connections: Vec::new(), refs: Vec::new(), + branch_starts: Vec::new(), }) .collect(), }) @@ -219,8 +220,8 @@ impl Client { .map_err(Error::generated) } - async fn pull_refs(&self, repository: &RepositoryId) -> Result { - let mut refs: PullRefs = HashMap::new(); + async fn pull_refs(&self, repository: &RepositoryId) -> Result { + let mut refs = PullMetadata::default(); for page in 1.. { let batch = self.repository_pulls(repository, "all", page, 100).await?; for pull in batch.items { @@ -230,7 +231,17 @@ impl Client { continue; }; 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 { @@ -281,13 +292,25 @@ impl Client { } } +#[derive(Default)] +struct PullMetadata { + tips: HashMap>, + starts: Vec, +} + +struct PullBranch { + head: String, + base: String, + label: String, +} + fn build_graph( histories: Vec<(usize, String, Vec)>, - mut extra_refs: PullRefs, + extra_refs: PullMetadata, ) -> Vec { let mut commits = HashMap::new(); let mut ranks: HashMap = HashMap::new(); - let mut refs: PullRefs = HashMap::new(); + let mut refs: HashMap> = HashMap::new(); for (branch_index, branch, history) in histories { if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) { refs.entry(sha).or_default().push(branch); @@ -303,7 +326,7 @@ fn build_graph( 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(); for label in labels { 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(); for sha in commits.keys() { @@ -350,12 +374,46 @@ fn build_graph( top_connections: Vec::new(), bottom_connections: Vec::new(), refs: refs.remove(&sha).unwrap_or_default(), + branch_starts: branch_starts.remove(&sha).unwrap_or_default(), }); } layout_graph(&mut ordered); ordered } +fn branch_starts( + commits: &HashMap, + branches: Vec, +) -> HashMap> { + let mut starts: HashMap> = 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, + head: &str, + base: &str, +) -> Option { + 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]) { let mut lanes: Vec> = Vec::new(); for row in commits { @@ -476,7 +534,7 @@ mod tests { vec![commit("right", &["base"]), commit("base", &[])], ), ], - HashMap::new(), + PullMetadata::default(), ); assert_eq!(rows.len(), 4); assert!(rows.iter().any(|row| row.refs == ["main"])); @@ -499,4 +557,41 @@ mod tests { assert_eq!(base.node_lane, Some(0)); 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()) + ); + } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 25573b0..5e45c74 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -1509,6 +1509,7 @@ public struct CommitRow: Equatable, Hashable { public var sha: String public var title: String public var detail: String + public var branchLabel: String? public var topLanes: [UInt32] public var bottomLanes: [UInt32] public var nodeLane: UInt32? @@ -1517,10 +1518,11 @@ public struct CommitRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // 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.title = title self.detail = detail + self.branchLabel = branchLabel self.topLanes = topLanes self.bottomLanes = bottomLanes self.nodeLane = nodeLane @@ -1547,6 +1549,7 @@ public struct FfiConverterTypeCommitRow: FfiConverterRustBuffer { sha: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf), detail: FfiConverterString.read(from: &buf), + branchLabel: FfiConverterOptionString.read(from: &buf), topLanes: FfiConverterSequenceUInt32.read(from: &buf), bottomLanes: FfiConverterSequenceUInt32.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.title, into: &buf) FfiConverterString.write(value.detail, into: &buf) + FfiConverterOptionString.write(value.branchLabel, into: &buf) FfiConverterSequenceUInt32.write(value.topLanes, into: &buf) FfiConverterSequenceUInt32.write(value.bottomLanes, into: &buf) FfiConverterOptionUInt32.write(value.nodeLane, into: &buf) diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index d4075f1..4944869 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -1309,7 +1309,17 @@ final class CommitCell: UITableViewCell { ? 0 : CGFloat(laneCount) * Self.laneSpacing + 10 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 contentConfiguration = content setNeedsDisplay()