diff --git a/crates/core/src/pull_request.rs b/crates/core/src/pull_request.rs index d5804d9f..7c3d275d 100644 --- a/crates/core/src/pull_request.rs +++ b/crates/core/src/pull_request.rs @@ -624,7 +624,9 @@ pub struct PullRequestSnapshot { pub additions: Option, #[serde(default)] pub deletions: Option, - pub changed_files: u64, + /// `None` where the host gave no count of changed files. + #[serde(default)] + pub changed_files: Option, pub review_decision: Option, pub checks_state: Option, #[serde(default)] @@ -1765,7 +1767,7 @@ mod tests { author: None, additions: Some(0), deletions: Some(0), - changed_files: 0, + changed_files: Some(0), review_decision: None, checks_state: None, mergeability: Mergeability::Unknown, @@ -1776,15 +1778,29 @@ mod tests { } } /// A snapshot thread metadata kept before counts could be absent still reads its counts; a - /// host that gives none is read as giving none, not as zero lines. + /// host that gives none is read as giving none, not as zero lines or files. #[test] - fn a_kept_snapshot_reads_its_line_counts() { + fn a_kept_snapshot_reads_its_counts() { let kept = r#"{"state":"open","title":"Change","head_branch":"feature","base_branch":"main","is_draft":false,"updated_at":"2026-10-08T00:00:00Z","synced_at":1,"closed_at":null,"merged_at":null,"author":null,"additions":3,"deletions":1,"changed_files":2,"review_decision":null,"checks_state":null,"mergeability":"clean"}"#; let snapshot: PullRequestSnapshot = serde_json::from_str(kept).unwrap(); - assert_eq!((snapshot.additions, snapshot.deletions), (Some(3), Some(1))); - let uncounted = kept.replace(r#""additions":3,"deletions":1,"#, ""); + assert_eq!( + ( + snapshot.additions, + snapshot.deletions, + snapshot.changed_files + ), + (Some(3), Some(1), Some(2)) + ); + let uncounted = kept.replace(r#""additions":3,"deletions":1,"changed_files":2,"#, ""); let snapshot: PullRequestSnapshot = serde_json::from_str(&uncounted).unwrap(); - assert_eq!((snapshot.additions, snapshot.deletions), (None, None)); + assert_eq!( + ( + snapshot.additions, + snapshot.deletions, + snapshot.changed_files + ), + (None, None, None) + ); } #[test] fn badge_distinguishes_unknown_drafts_terminal_and_related_links() { diff --git a/crates/core/src/settlement.rs b/crates/core/src/settlement.rs index be8ce824..be9976d3 100644 --- a/crates/core/src/settlement.rs +++ b/crates/core/src/settlement.rs @@ -403,7 +403,7 @@ mod tests { author: None, additions: Some(1), deletions: Some(1), - changed_files: 1, + changed_files: Some(1), review_decision: None, checks_state: None, mergeability: Mergeability::Unknown, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 0316d60c..754fd6e3 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -167,6 +167,8 @@ pub use wire::{ // SetHostToken carries an optional email. // Unreleased: a source-control host's kind may be azure, whose hosts are // organizations, dev.azure.com/{organization}. +// Unreleased: a pull request snapshot's changed_files may be absent (null) where +// the host counts no files. pub const PROTOCOL_VERSION: u32 = 10; #[cfg(test)] diff --git a/crates/runtime/src/app/pull_request_watch_tests.rs b/crates/runtime/src/app/pull_request_watch_tests.rs index 14fdfe69..1191e548 100644 --- a/crates/runtime/src/app/pull_request_watch_tests.rs +++ b/crates/runtime/src/app/pull_request_watch_tests.rs @@ -828,7 +828,7 @@ fn agent_starts_are_refused_where_the_parent_owns_the_pull_request_or_it_merged( author: None, additions: Some(0), deletions: Some(0), - changed_files: 0, + changed_files: Some(0), review_decision: None, checks_state: None, mergeability: Default::default(), diff --git a/crates/services/src/azure/reads.rs b/crates/services/src/azure/reads.rs index fdb07c0a..afacd569 100644 --- a/crates/services/src/azure/reads.rs +++ b/crates/services/src/azure/reads.rs @@ -449,7 +449,7 @@ pub(super) fn snapshot( // Azure counts no lines; only the files read with both sides of each would. additions: None, deletions: None, - changed_files: 0, + changed_files: None, review_decision: review_decision(pr), checks_state: checks_state(checks), mergeability, diff --git a/crates/services/src/bitbucket/reads.rs b/crates/services/src/bitbucket/reads.rs index 76e549c2..2be1fa7d 100644 --- a/crates/services/src/bitbucket/reads.rs +++ b/crates/services/src/bitbucket/reads.rs @@ -247,7 +247,7 @@ pub(super) fn snapshot( // Only the diffstat counts lines, one more read per file page. additions: None, deletions: None, - changed_files: 0, + changed_files: None, review_decision: None, checks_state: checks_state(checks), // Conflicts are a read of their own, which the summary leaves to the detail. diff --git a/crates/services/src/forgejo/reads.rs b/crates/services/src/forgejo/reads.rs index 6b6cba29..5980dc3c 100644 --- a/crates/services/src/forgejo/reads.rs +++ b/crates/services/src/forgejo/reads.rs @@ -212,7 +212,7 @@ pub(super) fn snapshot( }), additions: Some(pr["additions"].as_u64().unwrap_or(0)), deletions: Some(pr["deletions"].as_u64().unwrap_or(0)), - changed_files: pr["changed_files"].as_u64().unwrap_or(0), + changed_files: Some(pr["changed_files"].as_u64().unwrap_or(0)), review_decision: None, checks_state: checks, mergeability, diff --git a/crates/services/src/github/pull_requests.rs b/crates/services/src/github/pull_requests.rs index c35214b6..eb3d00fd 100644 --- a/crates/services/src/github/pull_requests.rs +++ b/crates/services/src/github/pull_requests.rs @@ -453,7 +453,7 @@ fn decode_summary(raw: &Value, key: &PullRequestKey) -> Option { }), additions: Some(raw["additions"].as_u64().unwrap_or(0)), deletions: Some(raw["deletions"].as_u64().unwrap_or(0)), - changed_files: raw["changedFiles"].as_u64().unwrap_or(0), + changed_files: Some(raw["changedFiles"].as_u64().unwrap_or(0)), review_decision: review_decision(raw), checks_state: checks, mergeability: match raw["mergeable"].as_str() { diff --git a/crates/services/src/gitlab/reads.rs b/crates/services/src/gitlab/reads.rs index bb396022..266a0ad5 100644 --- a/crates/services/src/gitlab/reads.rs +++ b/crates/services/src/gitlab/reads.rs @@ -418,7 +418,7 @@ pub(super) fn snapshot( }), additions: stats.map(|(additions, _)| additions), deletions: stats.map(|(_, deletions)| deletions), - changed_files: changed_files(mr), + changed_files: Some(changed_files(mr)), review_decision: None, checks_state: checks_state(&checks(mr)), mergeability, diff --git a/crates/ui/src/pull_requests/detail.rs b/crates/ui/src/pull_requests/detail.rs index bb8dc09b..cbf5d010 100644 --- a/crates/ui/src/pull_requests/detail.rs +++ b/crates/ui/src/pull_requests/detail.rs @@ -1593,16 +1593,29 @@ impl PullRequestView { .into() } + /// The Files tab's label, counted by the files read once it answers and by the snapshot + /// before that; a host that gives no count shows none rather than zero. + fn files_label(&self, cx: &App) -> String { + let read = self + .page() + .and_then(|page| page.files.data.as_ref()) + .map(|files| files.changed_files); + let count = read.or_else(|| { + self.link(cx) + .and_then(|link| link.snapshot.as_ref().and_then(|s| s.changed_files)) + }); + count_label( + "pull_requests.detail.files", + "pull_requests.detail.files_count", + count, + ) + } + fn switch(&self, cx: &mut Context) -> AnyElement { let compact = self.compact(cx); let tab = self.tab(cx); let page = self.page(); - let files = page.and_then(|page| page.files.data.as_ref()); let link = self.link(cx); - let file_count = files.map(|files| files.changed_files).or_else(|| { - link.as_ref() - .and_then(|link| link.snapshot.as_ref().map(|s| s.changed_files)) - }); let conversation_count = page.and_then(|page| page.conversation.data.as_ref()) .map(|conversation| { @@ -1615,23 +1628,11 @@ impl PullRequestView { }) .count() }); - let label = |plain: &str, counted: &str, count: Option| match count { - Some(count) => crate::tr!(counted, count = count.to_string()).into_owned(), - None => crate::tr!(plain).into_owned(), - }; let segments = [ - ( - "files", - label( - "pull_requests.detail.files", - "pull_requests.detail.files_count", - file_count, - ), - Tab::Files, - ), + ("files", self.files_label(cx), Tab::Files), ( "conversation", - label( + count_label( "pull_requests.detail.conversation", "pull_requests.detail.conversation_count", conversation_count.map(|count| count as u64), @@ -1986,6 +1987,13 @@ impl Render for PullRequestView { } } +fn count_label(plain: &str, counted: &str, count: Option) -> String { + match count { + Some(count) => crate::tr!(counted, count = count.to_string()).into_owned(), + None => crate::tr!(plain).into_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -2237,6 +2245,94 @@ mod tests { assert!(reads(&requests).is_empty()); } + /// A host that gives no count of changed files (Bitbucket, Azure DevOps) shows the Files tab + /// without a number until the files read answers with one, never "Files 0". + #[gpui::test] + fn a_snapshot_without_a_file_count_labels_the_files_tab_without_one(cx: &mut TestAppContext) { + let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + let Mounted { + view, + requests, + incoming, + _pump, + cx, + } = mount(cx); + let mut meta = tcode_core::project::SessionMeta::new( + agent::ProviderKind::ClaudeCode, + "/tmp/tcode-pr".into(), + None, + ); + meta.id = "session".into(); + meta.pull_requests = vec![ThreadPullRequestLink { + key: PullRequestKey::new("github.com", "octo/repo", 7), + url: "https://github.com/octo/repo/pull/7".into(), + source: PullRequestSource::Manual, + linked_at: None, + snapshot: Some(tcode_core::pull_request::PullRequestSnapshot { + state: PullRequestState::Open, + title: "Change".into(), + head_branch: "feature".into(), + base_branch: "main".into(), + is_draft: false, + updated_at: "2026-10-01T00:00:00Z".into(), + synced_at: 1, + closed_at: None, + merged_at: None, + author: None, + additions: None, + deletions: None, + changed_files: None, + review_decision: None, + checks_state: None, + mergeability: Mergeability::Unknown, + }), + stack: Default::default(), + sync_error: None, + watch: None, + }]; + incoming + .try_send( + encode_line(&HostMessage::Event(tcode_protocol::EventEnvelope { + request_id: None, + topic: tcode_protocol::Topic::Index, + event: tcode_protocol::ServerEvent::IndexSnapshot( + tcode_protocol::IndexSnapshot { + summary: Default::default(), + sessions: vec![meta], + projects: Vec::new(), + }, + ), + })) + .unwrap(), + ) + .unwrap(); + settle(cx); + let store = view.read_with(cx, |view, _| view.store.clone()); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + settle(cx); + let label = + |cx: &mut VisualTestContext| view.read_with(cx, |view, cx| view.files_label(cx)); + assert_eq!(label(cx), "Files"); + + let later = now() + 3600; + let (id, _) = reads(&requests) + .into_iter() + .find(|(_, read)| *read == PullRequestRead::Files { cursor: None }) + .expect("the first page of files is read"); + answer( + &incoming, + id, + PullRequestReadResponse::Files(files(0..3, None)), + later, + ); + settle(cx); + assert_eq!( + label(cx), + "Files 150", + "the files read's count once it answers" + ); + } + #[gpui::test] fn switching_language_relocalizes_the_file_filter_placeholder(cx: &mut TestAppContext) { let _locale_guard = crate::settings::TestLocaleGuard::acquire();