diff --git a/crates/git_ui/src/pr_review.rs b/crates/git_ui/src/pr_review.rs index b3ef65dee43c42..0a59ef2a4ef602 100644 --- a/crates/git_ui/src/pr_review.rs +++ b/crates/git_ui/src/pr_review.rs @@ -1,14 +1,17 @@ -//! A read-and-reply GitHub pull request comments pane. The command -//! `review: open pr comments` opens the pull request's comment threads in a -//! split beside the current view. It resolves the pull request from the current -//! branch (or a pasted URL), and replies/comments are posted to GitHub using the -//! user's token. +//! A GitHub pull request review view. Two commands open it from the palette: +//! `review: open review pane` shows the full Conversation UI (tabbed header, +//! timeline, checks, deployments, sidebar) as a tab in the current window, and +//! `review: open pr comments` opens the comment threads in a split beside the +//! current view. Comments and review threads are replyable, and a review can be +//! submitted (Approve / Request changes / Comment); writes authenticate with the +//! user's GitHub token. use anyhow::{Context as _, Result, bail}; use collections::HashMap; use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, + App, AppContext as _, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Task, + WeakEntity, Window, uniform_list, }; use http_client::{AsyncBody, HttpClient, HttpRequestExt as _, Request}; use serde::Deserialize; @@ -24,21 +27,56 @@ use editor::{Editor, EditorEvent}; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +/// Open the full GitHub pull request review pane in a new window. +#[derive(Clone, Default, PartialEq, gpui::Action)] +#[action(namespace = review)] +pub struct OpenReviewPane; + /// Open the pull request's comment threads beside the current view. #[derive(Clone, Default, PartialEq, gpui::Action)] #[action(namespace = review)] pub struct OpenPrComments; pub fn register(workspace: &mut Workspace, _cx: &mut Context) { + workspace.register_action( + |workspace, _: &OpenReviewPane, window, cx: &mut Context| { + let branch_target = detect_branch_target(workspace, cx); + let view = cx.new(|cx| PrReviewView::new(Mode::Full, branch_target, window, cx)); + workspace.add_item_to_active_pane(Box::new(view), None, true, window, cx); + }, + ); workspace.register_action( |workspace, _: &OpenPrComments, window, cx: &mut Context| { let branch_target = detect_branch_target(workspace, cx); - let view = cx.new(|cx| PrCommentsView::new(branch_target, window, cx)); + let view = cx.new(|cx| PrReviewView::new(Mode::Comments, branch_target, window, cx)); workspace.split_item(SplitDirection::Right, Box::new(view), window, cx); }, ); } +#[derive(Clone, Copy, PartialEq)] +enum Mode { + Full, + Comments, +} + +#[derive(Clone, Copy, PartialEq)] +enum Tab { + Conversation, + Commits, + Checks, + Files, +} + +enum DiffRow { + Header { text: SharedString }, + Line { + text: SharedString, + background: Option, + accent: bool, + }, +} + #[derive(Clone)] struct BranchTarget { host: String, @@ -128,8 +166,18 @@ fn login(user: Option<&GithubUser>) -> String { #[derive(Debug, Deserialize)] struct GitRef { + #[serde(default)] + label: String, #[serde(default)] r#ref: String, + #[serde(default)] + sha: String, +} + +#[derive(Debug, Deserialize)] +struct NamedItem { + #[serde(default)] + name: String, } #[derive(Debug, Deserialize)] @@ -140,8 +188,33 @@ struct PullRequestResponse { #[serde(default)] body: Option, #[serde(default)] + state: String, + #[serde(default)] + draft: bool, + #[serde(default)] + merged: bool, + #[serde(default)] + mergeable_state: String, + #[serde(default)] user: Option, + #[serde(default)] + additions: u64, + #[serde(default)] + deletions: u64, + #[serde(default)] + changed_files: u64, + #[serde(default)] + commits: u64, + base: GitRef, head: GitRef, + #[serde(default)] + requested_reviewers: Vec, + #[serde(default)] + assignees: Vec, + #[serde(default)] + labels: Vec, + #[serde(default)] + milestone: Option, } #[derive(Debug, Deserialize)] @@ -149,11 +222,23 @@ struct TimelineEvent { #[serde(default)] event: String, #[serde(default)] + actor: Option, + #[serde(default)] user: Option, #[serde(default)] body: Option, #[serde(default)] created_at: Option, + #[serde(default)] + sha: Option, + #[serde(default)] + message: Option, + #[serde(default)] + state: Option, + #[serde(default)] + requested_reviewer: Option, + #[serde(default)] + label: Option, } #[derive(Debug, Deserialize)] @@ -174,6 +259,42 @@ struct ReviewComment { created_at: Option, } +#[derive(Debug, Default, Deserialize)] +struct CheckRunsResponse { + #[serde(default)] + check_runs: Vec, +} + +#[derive(Debug, Deserialize)] +struct CheckRun { + #[serde(default)] + name: String, + #[serde(default)] + status: String, + #[serde(default)] + conclusion: Option, +} + +#[derive(Debug, Deserialize)] +struct Deployment { + #[serde(default)] + environment: String, +} + +#[derive(Debug, Deserialize)] +struct ChangedFile { + #[serde(default)] + filename: String, + #[serde(default)] + status: String, + #[serde(default)] + additions: u64, + #[serde(default)] + deletions: u64, + #[serde(default)] + patch: Option, +} + #[derive(Debug, Default, Deserialize)] struct RepoResponse { #[serde(default)] @@ -202,7 +323,16 @@ struct LoadedPullRequest { number: u64, pull_request: PullRequestResponse, timeline: Vec, + checks: Vec, + deployments: Vec, review_comments: Vec, + files: Vec, +} + +impl LoadedPullRequest { + fn commented_events(&self) -> impl Iterator { + self.timeline.iter().filter(|event| event.event == "commented") + } } enum ViewState { @@ -211,18 +341,26 @@ enum ViewState { Error(SharedString), } -pub struct PrCommentsView { +pub struct PrReviewView { focus_handle: FocusHandle, input: Entity, comment_editor: Entity, + review_editor: Entity, reply_editors: HashMap>, branch_target: Option, + mode: Mode, + active_tab: Tab, state: ViewState, _load_task: Option>, } -impl PrCommentsView { - fn new(branch_target: Option, window: &mut Window, cx: &mut Context) -> Self { +impl PrReviewView { + fn new( + mode: Mode, + branch_target: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { let placeholder = match &branch_target { Some(target) => format!( "PR for {}/{} @ {} — or paste a URL", @@ -240,13 +378,21 @@ impl PrCommentsView { editor.set_placeholder_text("Add a comment", window, cx); editor }); + let review_editor = cx.new(|cx| { + let mut editor = Editor::auto_height(1, 6, window, cx); + editor.set_placeholder_text("Leave a review summary…", window, cx); + editor + }); let mut this = Self { focus_handle: cx.focus_handle(), input, comment_editor, + review_editor, reply_editors: HashMap::default(), branch_target, + mode, + active_tab: Tab::Conversation, state: ViewState::Loading, _load_task: None, }; @@ -333,6 +479,7 @@ impl PrCommentsView { )) } + /// POST a JSON payload, then clear the originating editor and reload on success. fn post_json( &mut self, url: String, @@ -395,6 +542,22 @@ impl PrCommentsView { cx, ); } + + fn submit_review(&mut self, event: &'static str, window: &mut Window, cx: &mut Context) { + let Some(base) = self.repo_url() else { + return; + }; + let number = self.loaded().map(|loaded| loaded.number).unwrap_or_default(); + let body = self.review_editor.read(cx).text(cx); + let payload = serde_json::json!({ "event": event, "body": body }).to_string(); + self.post_json( + format!("{base}/pulls/{number}/reviews"), + payload, + self.review_editor.clone(), + window, + cx, + ); + } } enum Target { @@ -473,6 +636,8 @@ async fn github_post( Ok(()) } +/// Fetch and deserialize best-effort auxiliary data, logging failures and +/// falling back to the default rather than failing the whole load. async fn github_get_or_default( url: String, token: Option<&str>, @@ -503,6 +668,8 @@ async fn resolve_pull_request_ref( number, }; + // The PR may live on the origin repo (same-repo branch) or, for a fork, on + // its parent repo keyed by the head "{fork_owner}:{branch}". let on_origin: Vec = github_get_or_default( format!("https://{host}/repos/{}/{}/pulls?head={head}&state=open", branch.owner, branch.repo), token, @@ -560,17 +727,33 @@ async fn load_pull_request(target: Target, client: Arc) -> Resul serde_json::from_slice(&body).context("failed to parse pull request")? }; - let (timeline, review_comments) = futures::join!( + let sha = &pull_request.head.sha; + let (timeline, checks, deployments, review_comments, files) = futures::join!( github_get_or_default::>( format!("{repo_url}/issues/{number}/timeline?per_page=100"), token.as_deref(), &client, ), + github_get_or_default::( + format!("{repo_url}/commits/{sha}/check-runs"), + token.as_deref(), + &client, + ), + github_get_or_default::>( + format!("{repo_url}/deployments?sha={sha}"), + token.as_deref(), + &client, + ), github_get_or_default::>( format!("{repo_url}/pulls/{number}/comments?per_page=100"), token.as_deref(), &client, ), + github_get_or_default::>( + format!("{repo_url}/pulls/{number}/files?per_page=100"), + token.as_deref(), + &client, + ), ); Ok(LoadedPullRequest { @@ -580,7 +763,10 @@ async fn load_pull_request(target: Target, client: Arc) -> Resul number, pull_request, timeline, + checks: checks.check_runs, + deployments, review_comments, + files, }) } @@ -590,7 +776,27 @@ fn short_date(date: &Option) -> String { .unwrap_or_default() } -impl PrCommentsView { +fn is_failing(run: &CheckRun) -> bool { + matches!( + run.conclusion.as_deref(), + Some("failure") | Some("timed_out") | Some("cancelled") | Some("action_required") + | Some("startup_failure") + ) +} + +fn check_icon(run: &CheckRun) -> (IconName, Color) { + if run.status != "completed" { + (IconName::ArrowCircle, Color::Muted) + } else { + match run.conclusion.as_deref() { + Some("success") => (IconName::Check, Color::Success), + Some("neutral") | Some("skipped") => (IconName::Dash, Color::Muted), + _ => (IconName::Close, Color::Error), + } + } +} + +impl PrReviewView { fn boxed(&self, cx: &Context) -> Div { v_flex() .w_full() @@ -600,6 +806,70 @@ impl PrCommentsView { .bg(cx.theme().colors().elevated_surface_background) } + fn render_header(&self, loaded: &LoadedPullRequest, cx: &Context) -> impl IntoElement { + let pr = &loaded.pull_request; + let (state_label, state_color) = if pr.merged { + ("Merged", Color::Accent) + } else if pr.state == "closed" { + ("Closed", Color::Error) + } else if pr.draft { + ("Draft", Color::Muted) + } else { + ("Open", Color::Success) + }; + let comments = loaded.commented_events().count(); + + v_flex() + .w_full() + .gap_2() + .pb_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .gap_2() + .child(Label::new(pr.title.clone()).size(LabelSize::Large)) + .child( + Label::new(format!("#{}", pr.number)) + .size(LabelSize::Large) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .px_2() + .py_1() + .rounded_md() + .bg(cx.theme().colors().element_background) + .child(Label::new(state_label).color(state_color)), + ) + .child( + Label::new(format!( + "{} wants to merge {} commits into {} from {}", + login(pr.user.as_ref()), + pr.commits, + pr.base.r#ref, + pr.head.label + )) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_2() + .child(self.render_tab(Tab::Conversation, format!("Conversation {comments}"), cx)) + .child(self.render_tab(Tab::Commits, format!("Commits {}", pr.commits), cx)) + .child(self.render_tab(Tab::Checks, format!("Checks {}", loaded.checks.len()), cx)) + .child(self.render_tab(Tab::Files, format!("Files changed {}", pr.changed_files), cx)) + .child(div().flex_1()) + .child(Label::new(format!("+{}", pr.additions)).color(Color::Created)) + .child(Label::new(format!("-{}", pr.deletions)).color(Color::Deleted)), + ) + } + fn comment_card( &self, user: Option<&GithubUser>, @@ -636,6 +906,85 @@ impl PrCommentsView { ) } + fn event_row(&self, icon: IconName, text: String) -> Div { + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(Icon::new(icon).color(Color::Muted).size(IconSize::Small)) + .child(Label::new(text).color(Color::Muted).size(LabelSize::Small)) + } + + fn render_timeline_event( + &self, + event: &TimelineEvent, + cx: &Context, + ) -> Option { + let actor = login(event.actor.as_ref().or(event.user.as_ref())); + let (icon, text) = match event.event.as_str() { + "commented" => { + return Some( + self.comment_card( + event.user.as_ref(), + event.body.as_deref().unwrap_or_default(), + &event.created_at, + cx, + ) + .into_any_element(), + ); + } + "reviewed" => { + let body = event.body.as_deref().unwrap_or_default(); + if !body.trim().is_empty() { + return Some( + self.comment_card(event.user.as_ref(), body, &event.created_at, cx) + .into_any_element(), + ); + } + ( + IconName::Check, + format!("{actor} reviewed ({})", event.state.as_deref().unwrap_or_default()), + ) + } + "committed" => { + let sha: String = event.sha.as_deref().unwrap_or_default().chars().take(7).collect(); + let message = event + .message + .as_deref() + .unwrap_or_default() + .lines() + .next() + .unwrap_or_default() + .to_string(); + return Some( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(Icon::new(IconName::GitCommit).color(Color::Muted).size(IconSize::Small)) + .child(Label::new(message).size(LabelSize::Small)) + .child(Label::new(sha).color(Color::Muted).size(LabelSize::Small)) + .into_any_element(), + ); + } + "review_requested" => ( + IconName::Person, + format!("{actor} requested a review from {}", login(event.requested_reviewer.as_ref())), + ), + "head_ref_force_pushed" => (IconName::ArrowUp, format!("{actor} force-pushed")), + "labeled" => { + let label = event.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(); + (IconName::Hash, format!("{actor} added the {label} label")) + } + "merged" => (IconName::Check, format!("{actor} merged this pull request")), + "closed" => (IconName::Close, format!("{actor} closed this pull request")), + _ => return None, + }; + Some(self.event_row(icon, text).into_any_element()) + } + fn render_comment_thread( &self, root: &ReviewComment, @@ -709,6 +1058,279 @@ impl PrCommentsView { ) } + fn render_review_box(&self, cx: &Context) -> impl IntoElement { + self.boxed(cx) + .mt_4() + .p_2() + .gap_2() + .child(Label::new("Finish your review").size(LabelSize::Small).color(Color::Muted)) + .child(self.review_editor.clone()) + .child( + h_flex() + .gap_2() + .child( + Button::new("review-approve", "Approve") + .on_click(cx.listener(|this, _, window, cx| { + this.submit_review("APPROVE", window, cx) + })), + ) + .child( + Button::new("review-request-changes", "Request changes").on_click( + cx.listener(|this, _, window, cx| { + this.submit_review("REQUEST_CHANGES", window, cx) + }), + ), + ) + .child( + Button::new("review-comment", "Comment") + .on_click(cx.listener(|this, _, window, cx| { + this.submit_review("COMMENT", window, cx) + })), + ), + ) + } + + fn render_tab(&self, tab: Tab, label: String, cx: &Context) -> impl IntoElement { + let selected = self.active_tab == tab; + let id: ElementId = match tab { + Tab::Conversation => "pr-tab-conversation", + Tab::Commits => "pr-tab-commits", + Tab::Checks => "pr-tab-checks", + Tab::Files => "pr-tab-files", + } + .into(); + div() + .id(id) + .px_2() + .py_1() + .cursor_pointer() + .when(selected, |this| { + this.border_b_2().border_color(cx.theme().colors().text_accent) + }) + .child(Label::new(label).color(if selected { Color::Default } else { Color::Muted })) + .on_click(cx.listener(move |this, _, _window, cx| { + this.active_tab = tab; + cx.notify(); + })) + } + + fn render_commits(&self, loaded: &LoadedPullRequest, cx: &Context) -> Vec { + loaded + .timeline + .iter() + .filter(|event| event.event == "committed") + .map(|event| { + let sha: String = event.sha.as_deref().unwrap_or_default().chars().take(7).collect(); + let message = event + .message + .as_deref() + .unwrap_or_default() + .lines() + .next() + .unwrap_or_default() + .to_string(); + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Icon::new(IconName::GitCommit).color(Color::Muted).size(IconSize::Small)) + .child(Label::new(message)) + .child(Label::new(sha).color(Color::Muted).size(LabelSize::Small).buffer_font(cx)) + .into_any_element() + }) + .collect() + } + + fn render_files(&self, loaded: &LoadedPullRequest, cx: &Context) -> impl IntoElement { + let created = cx.theme().status().created_background; + let deleted = cx.theme().status().deleted_background; + let header_bg = cx.theme().colors().element_background; + let border = cx.theme().colors().border; + + let mut rows: Vec = Vec::new(); + for file in &loaded.files { + rows.push(DiffRow::Header { + text: format!( + "{} {} +{} -{}", + file.filename, file.status, file.additions, file.deletions + ) + .into(), + }); + if let Some(patch) = &file.patch { + for line in patch.split('\n') { + rows.push(DiffRow::Line { + text: line.to_string().into(), + background: match line.chars().next() { + Some('+') => Some(created), + Some('-') => Some(deleted), + _ => None, + }, + accent: line.starts_with('@'), + }); + } + } + } + + uniform_list("pr-review-files", rows.len(), move |range, _window, _cx| { + range + .map(|index| match &rows[index] { + DiffRow::Header { text } => div() + .w_full() + .px_3() + .py_1() + .bg(header_bg) + .border_t_1() + .border_color(border) + .child(Label::new(text.clone()).buffer_font(_cx)) + .into_any_element(), + DiffRow::Line { text, background, accent } => { + let mut row = div().w_full().px_3(); + if let Some(background) = background { + row = row.bg(*background); + } + row.child( + Label::new(text.clone()) + .buffer_font(_cx) + .color(if *accent { Color::Accent } else { Color::Default }), + ) + .into_any_element() + } + }) + .collect() + }) + .flex_1() + } + + fn render_deployments(&self, loaded: &LoadedPullRequest, cx: &Context) -> impl IntoElement { + let text = if loaded.deployments.is_empty() { + "This branch has not been deployed".to_string() + } else { + format!( + "Deployed to {}", + loaded + .deployments + .iter() + .map(|deployment| deployment.environment.as_str()) + .collect::>() + .join(", ") + ) + }; + self.boxed(cx).mt_4().child( + h_flex() + .w_full() + .p_3() + .gap_2() + .child(Icon::new(IconName::Server).color(Color::Muted)) + .child(Label::new(text)), + ) + } + + fn render_checks(&self, loaded: &LoadedPullRequest, cx: &Context) -> impl IntoElement { + let failing = loaded.checks.iter().filter(|run| is_failing(run)).count(); + let pending = loaded.checks.iter().filter(|run| run.status != "completed").count(); + let (summary_icon, summary_color, summary) = if loaded.checks.is_empty() { + (IconName::Check, Color::Muted, "No checks reported".to_string()) + } else if failing > 0 { + (IconName::Close, Color::Error, format!("Some checks were not successful — {failing} failing")) + } else if pending > 0 { + (IconName::ArrowCircle, Color::Muted, format!("Checks are still running — {pending} pending")) + } else { + (IconName::Check, Color::Success, "All checks have passed".to_string()) + }; + + self.boxed(cx) + .mt_4() + .child( + h_flex() + .w_full() + .p_3() + .gap_2() + .child(Icon::new(summary_icon).color(summary_color)) + .child(Label::new(summary)), + ) + .children(loaded.checks.iter().map(|run| { + let (icon, color) = check_icon(run); + h_flex() + .w_full() + .px_3() + .py_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(Icon::new(icon).color(color).size(IconSize::Small)) + .child(Label::new(run.name.clone()).size(LabelSize::Small)) + })) + } + + fn render_merge_status(&self, pr: &PullRequestResponse, cx: &Context) -> impl IntoElement { + let (icon, color, text) = if pr.merged { + (IconName::Check, Color::Accent, "Pull request merged".to_string()) + } else if pr.state == "closed" { + (IconName::Close, Color::Error, "Pull request closed".to_string()) + } else if pr.mergeable_state == "clean" { + (IconName::Check, Color::Success, "Ready to merge".to_string()) + } else { + ( + IconName::Warning, + Color::Warning, + format!("Merging is blocked ({})", pr.mergeable_state), + ) + }; + self.boxed(cx).mt_4().child( + h_flex() + .w_full() + .p_3() + .gap_2() + .child(Icon::new(icon).color(color)) + .child(Label::new(text)), + ) + } + + fn render_sidebar(&self, pr: &PullRequestResponse, cx: &Context) -> impl IntoElement { + let section = |title: &str, entries: Vec| { + let title = title.to_string(); + v_flex() + .w_full() + .py_2() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new(title).color(Color::Muted).size(LabelSize::Small)) + .children(if entries.is_empty() { + vec![Label::new("None yet").color(Color::Muted).size(LabelSize::Small)] + } else { + entries + .into_iter() + .map(|entry| Label::new(entry).size(LabelSize::Small)) + .collect() + }) + }; + + v_flex() + .w(px(240.)) + .flex_none() + .pl_4() + .child(section( + "Reviewers", + pr.requested_reviewers.iter().map(|user| user.login.clone()).collect(), + )) + .child(section( + "Assignees", + pr.assignees.iter().map(|user| user.login.clone()).collect(), + )) + .child(section( + "Labels", + pr.labels.iter().map(|label| label.name.clone()).collect(), + )) + .child(section( + "Milestone", + pr.milestone.iter().map(|milestone| milestone.name.clone()).collect(), + )) + } + fn render_body(&self, cx: &mut Context) -> AnyElement { match &self.state { ViewState::Loading => v_flex() @@ -731,57 +1353,87 @@ impl PrCommentsView { .into_any_element(), ViewState::Loaded(loaded) => { let pull_request = &loaded.pull_request; - let description = pull_request - .body - .as_deref() - .filter(|body| !body.trim().is_empty()) - .map(|body| self.comment_card(pull_request.user.as_ref(), body, &None, cx)); - - v_flex() - .id("pr-comments") + let description = |this: &Self, cx: &Context| { + pull_request + .body + .as_deref() + .filter(|body| !body.trim().is_empty()) + .map(|body| this.comment_card(pull_request.user.as_ref(), body, &None, cx)) + }; + + let scroll_column = |id: &'static str| { + v_flex().id(id).flex_1().size_full().overflow_y_scroll().gap_2() + }; + let main = match (self.mode, self.active_tab) { + (Mode::Comments, _) => scroll_column("pr-comments") + .child( + Label::new(format!("{} #{}", pull_request.title, pull_request.number)) + .size(LabelSize::Large), + ) + .children(description(self, cx)) + .children( + loaded + .commented_events() + .filter_map(|event| self.render_timeline_event(event, cx)), + ) + .children(self.render_review_threads(loaded, cx)) + .child(self.render_comment_box(cx)), + (Mode::Full, Tab::Conversation) => scroll_column("pr-conversation") + .child(self.render_header(loaded, cx)) + .children(description(self, cx)) + .children( + loaded + .timeline + .iter() + .filter_map(|event| self.render_timeline_event(event, cx)), + ) + .children(self.render_review_threads(loaded, cx)) + .child(self.render_deployments(loaded, cx)) + .child(self.render_merge_status(pull_request, cx)) + .child(self.render_review_box(cx)) + .child(self.render_comment_box(cx)), + (Mode::Full, Tab::Commits) => scroll_column("pr-commits") + .child(self.render_header(loaded, cx)) + .children(self.render_commits(loaded, cx)), + (Mode::Full, Tab::Checks) => scroll_column("pr-checks") + .child(self.render_header(loaded, cx)) + .child(self.render_checks(loaded, cx)), + (Mode::Full, Tab::Files) => v_flex() + .id("pr-files") + .flex_1() + .size_full() + .overflow_hidden() + .gap_2() + .child(self.render_header(loaded, cx)) + .child(self.render_files(loaded, cx)), + }; + + let show_sidebar = self.mode == Mode::Full && self.active_tab == Tab::Conversation; + h_flex() .size_full() .p_4() - .gap_2() - .overflow_y_scroll() - .child( - Label::new(format!("{} #{}", pull_request.title, pull_request.number)) - .size(LabelSize::Large), - ) - .children(description) - .children( - loaded - .timeline - .iter() - .filter(|event| event.event == "commented") - .map(|event| { - self.comment_card( - event.user.as_ref(), - event.body.as_deref().unwrap_or_default(), - &event.created_at, - cx, - ) - }), - ) - .children(self.render_review_threads(loaded, cx)) - .child(self.render_comment_box(cx)) + .gap_4() + .items_start() + .child(main) + .when(show_sidebar, |this| this.child(self.render_sidebar(pull_request, cx))) .into_any_element() } } } } -impl Focusable for PrCommentsView { +impl Focusable for PrReviewView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } -impl EventEmitter for PrCommentsView {} +impl EventEmitter for PrReviewView {} -impl Render for PrCommentsView { +impl Render for PrReviewView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("PrComments") + .key_context("PrReview") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::confirm)) .size_full() @@ -810,13 +1462,13 @@ impl Render for PrCommentsView { } } -impl Item for PrCommentsView { +impl Item for PrReviewView { type Event = EditorEvent; fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { match &self.state { - ViewState::Loaded(loaded) => format!("PR #{} comments", loaded.pull_request.number).into(), - _ => "PR Comments".into(), + ViewState::Loaded(loaded) => format!("PR #{}", loaded.pull_request.number).into(), + _ => "Pull Request".into(), } } diff --git a/crates/git_ui/src/pr_review/README.md b/crates/git_ui/src/pr_review/README.md new file mode 100644 index 00000000000000..06dfdf226a9254 --- /dev/null +++ b/crates/git_ui/src/pr_review/README.md @@ -0,0 +1,10 @@ +# PR Review pane (`pr_review`) + +`review: open review pane` opens the full GitHub "Conversation" UI as a tab: +tabbed header (Conversation / Commits / Checks / Files changed) with diffstat, +the comment+commit+event timeline, CI checks, deployment status, merge status, +the reviewers/labels/assignees/milestone sidebar, and review submission +(Approve / Request changes / Comment). Files tab renders diffs via `uniform_list`. + +Builds on the comments pane (`review: open pr comments`); replies and comments +post to GitHub using GITHUB_TOKEN or the `gh` CLI token. See `../pr_review.rs`.