Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions crates/core/src/pull_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,9 @@ pub struct PullRequestSnapshot {
pub additions: Option<u64>,
#[serde(default)]
pub deletions: Option<u64>,
pub changed_files: u64,
/// `None` where the host gave no count of changed files.
#[serde(default)]
pub changed_files: Option<u64>,
pub review_decision: Option<ReviewDecision>,
pub checks_state: Option<ChecksState>,
#[serde(default)]
Expand Down Expand Up @@ -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,
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/src/app/pull_request_watch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/services/src/azure/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/services/src/bitbucket/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion crates/services/src/forgejo/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/services/src/github/pull_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ fn decode_summary(raw: &Value, key: &PullRequestKey) -> Option<Summary> {
}),
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() {
Expand Down
2 changes: 1 addition & 1 deletion crates/services/src/gitlab/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
134 changes: 115 additions & 19 deletions crates/ui/src/pull_requests/detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) -> 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| {
Expand All @@ -1615,23 +1628,11 @@ impl PullRequestView {
})
.count()
});
let label = |plain: &str, counted: &str, count: Option<u64>| 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),
Expand Down Expand Up @@ -1986,6 +1987,13 @@ impl Render for PullRequestView {
}
}

fn count_label(plain: &str, counted: &str, count: Option<u64>) -> 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::*;
Expand Down Expand Up @@ -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();
Expand Down
Loading