diff --git a/crates/assets/assets/icons/panel-bottom-open.svg b/crates/assets/assets/icons/panel-bottom-open.svg index df77e5b..58cb22a 100644 --- a/crates/assets/assets/icons/panel-bottom-open.svg +++ b/crates/assets/assets/icons/panel-bottom-open.svg @@ -1 +1,3 @@ - + + + diff --git a/crates/assets/assets/icons/panel-bottom.svg b/crates/assets/assets/icons/panel-bottom.svg index d70a752..e017db3 100644 --- a/crates/assets/assets/icons/panel-bottom.svg +++ b/crates/assets/assets/icons/panel-bottom.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index e7eb5aa..67ac752 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -391,6 +391,7 @@ impl CommitDiffView { v_flex() .px_4() + .pb_4() .w_full() .gap_4() .child( diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index 9e7651d..d16919a 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -1,30 +1,34 @@ use std::path::PathBuf; use std::rc::Rc; -use dock::{BasePanel, Panel, PanelEvent}; +use assets::CustomIconName; +use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, + ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, + size, }; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::input::{Textarea, TextareaState}; use gpui_component::list::ListItem; -use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::tag::Tag; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ - ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind, Nip34Tag}; +use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey}; use signed_core::{activity_subject, pull_request_patch}; use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs}; use signed_state::{GitStore, ProfileStore, RepoStore}; use utils::{relative_time, relative_time_secs}; +use super::diff::CommitDiffView; use super::helpers::{ DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row, status_badge, tree_items, tree_row, @@ -36,12 +40,19 @@ const TREE_WIDTH: f32 = 260.; /// Detail panel of a single pull request. pub struct PullRequestDetailView { focus_handle: FocusHandle, + /// Dock area new panels (commit diffs) are added to. + dock_area: WeakEntity, /// Repo store holding the PR, its status and comments. store: Entity, /// Event id of the root PR event (kind 1618; updates are revisions). pr_id: EventId, /// Input state of the "leave a comment" textarea. comment_input: Entity, + /// Display name of the repository, for panels opened from here. + repo_name: SharedString, + /// Local clone the PR's git changes come from; `None` while the diff is + /// parsed from the nostr patch set (no commit diff viewer then). + worktree: Option, /// Root PR's content, shown as plain text. description: SharedString, /// Tip commit of the PR: the latest update's `c` tag, else the root's. @@ -53,6 +64,8 @@ pub struct PullRequestDetailView { /// The patch is being parsed on a background task. loading: bool, error: Option, + /// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits. + active_tab: usize, /// Changed-files explorer state. tree_state: Entity, /// Path of the file whose diff is shown in the detail column. @@ -72,6 +85,7 @@ pub struct PullRequestDetailView { impl PullRequestDetailView { pub fn new( + dock_area: WeakEntity, store: Entity, pr_id: EventId, window: &mut Window, @@ -85,6 +99,19 @@ impl PullRequestDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…")); + // Same display name as the repo detail panel's title. + let repo_name = store + .read(cx) + .announcement + .as_ref() + .map(|announcement| { + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default(); + // Re-render when the store refreshes (new comments, status changes). let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())]; @@ -95,15 +122,19 @@ impl PullRequestDetailView { Self { focus_handle: cx.focus_handle(), + dock_area, store, pr_id, comment_input, + repo_name, + worktree: None, description: SharedString::default(), current_commit: None, commits: Vec::new(), diff: None, loading: true, error: None, + active_tab: 0, tree_state, selected_file: None, rows: Vec::new(), @@ -200,7 +231,8 @@ impl PullRequestDetailView { let repo = cache.ensure_clone(&addr, &clone_urls)?; let workdir = repo .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?; + .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? + .to_path_buf(); let tip = tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; let base = match base { @@ -215,23 +247,24 @@ impl PullRequestDetailView { repo.merge_base(tip_id, head)?.to_string() } }; - let diff = signed_git::worktree_commit_range_diff(workdir, &base, &tip)?; + let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; let commits = - signed_git::worktree_commit_range_commits(workdir, &base, &tip)?; - Ok::<_, anyhow::Error>((diff, commits)) + signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + Ok::<_, anyhow::Error>((diff, commits, workdir)) }) .await, ) }; - let (diff, commits) = match git { - Some(Ok((diff, commits))) => (Ok(diff), commits), - Some(Err(error)) => (Err(error), Vec::new()), - None => (nostr_diff, nostr_commits), + let (diff, commits, worktree) = match git { + Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), + Some(Err(error)) => (Err(error), Vec::new(), None), + None => (nostr_diff, nostr_commits, None), }; this.update_in(cx, |this, _window, cx| { this.loading = false; + this.worktree = worktree; this.current_commit = current_commit.map(SharedString::from); this.commits = commits; match diff { @@ -293,6 +326,33 @@ impl PullRequestDetailView { self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); } + /// Open the diff of `commit_id` in the bottom dock of the area. + fn open_commit_diff( + &mut self, + worktree: PathBuf, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| { + CommitDiffView::new( + worktree, + self.repo_name.clone(), + commit_id.into(), + window, + cx, + ) + }); + + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel_view(panel_handle(panel), DockPlacement::Bottom, None, window, cx); + }); + } + /// One row of the changed-files tree: icon + name, indented by depth. fn render_tree_item( ix: usize, @@ -487,9 +547,245 @@ impl PullRequestDetailView { .into_any_element() } - /// Top panel: title, status, author, description, commits and comments, - /// one scrollable column with the comment form pinned at the bottom. - fn render_conversation(&mut self, cx: &mut Context) -> AnyElement { + /// Underline tab bar: Discussion, Files and Commits. + fn render_tabs(&self, cx: &mut Context) -> AnyElement { + let active = self.active_tab; + let files_count = self.diff.as_ref().map(|diff| diff.files.len()); + let commits_count = if self.commits.is_empty() { + None + } else { + Some(self.commits.len()) + }; + + TabBar::new("pr-tabs") + .underline() + .small() + .px_4() + .w_full() + .selected_index(active) + .on_click(cx.listener(|this, index, _window, cx| { + this.active_tab = *index; + cx.notify(); + })) + .child(Tab::new().label("Discussion")) + .child( + Tab::new() + .label("Files") + .when_some(files_count, |this, count| { + this.suffix( + Tag::secondary() + .xsmall() + .child(SharedString::from(count.to_string())), + ) + }), + ) + .child( + Tab::new() + .label("Commits") + .when_some(commits_count, |this, count| { + this.suffix( + Tag::secondary() + .xsmall() + .child(SharedString::from(count.to_string())), + ) + }), + ) + .into_any_element() + } + + /// Discussion tab: author, description and comments like the issue + /// panel, with the comment form at the end and a sidebar on the right. + fn render_discussion(&mut self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(); + } + + if let Some(error) = self.error.clone() { + return placeholder(&error, cx); + } + + let (author, picture, age, root_id) = { + let store = self.store.read(cx); + let Some(root) = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + else { + return placeholder("Pull request not found", cx); + }; + let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey); + ( + profile.name(), + profile.picture(), + relative_time(root.created_at), + root.id, + ) + }; + + h_flex() + .flex_1() + .w_full() + .min_h_0() + .child( + v_flex() + .flex_1() + .min_w_0() + .p_4() + .gap_6() + .overflow_y_scrollbar() + .child( + v_flex() + .px_4() + .gap_8() + .child( + v_flex() + .gap_4() + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child( + Avatar::new() + .name(author.clone()) + .when_some(picture, |this, url| { + this.src(url) + }) + .rounded(cx.theme().radius) + .small(), + ) + .child(author), + ) + .child(SharedString::from("commented")) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(age)), + ), + ) + .when(!self.description.is_empty(), |this| { + this.child(div().text_sm().child(self.description.clone())) + }), + ) + .child(self.render_comments(&root_id, cx)) + .child(self.render_form(&root_id, cx)), + ), + ) + .child(self.render_sidebar(cx)) + .into_any_element() + } + + /// Right sidebar: participants and labels, like the issue panel. + fn render_sidebar(&self, cx: &mut Context) -> AnyElement { + let profile_store = ProfileStore::global(cx); + let store = self.store.read(cx); + + let Some(root) = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + else { + // `render_discussion` already bails out when the PR is missing. + return div().into_any_element(); + }; + + // Participants: the PR author plus everyone who commented. + let mut participants: Vec = vec![root.pubkey]; + participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); + participants.sort_by_key(PublicKey::to_hex); + participants.dedup(); + + // PR labels are NIP-34 `t` hashtag tags on the event. + let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); + + v_flex() + .w(px(240.)) + .h_full() + .flex_none() + .px_4() + .gap_4() + .border_l(px(1.)) + .border_color(cx.theme().sidebar_border) + .child( + v_flex() + .mt_4() + .gap_2() + .child(sidebar_title("Participants", cx)) + .children(participants.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new() + .name(name.clone()) + .when_some(picture, |this, url| this.src(url)) + .rounded(cx.theme().radius) + .small(), + ) + .child(div().text_sm().truncate().text_ellipsis().child(name)) + .into_any_element() + })), + ) + .child( + v_flex() + .gap_2() + .child(sidebar_title("Labels", cx)) + .map(|this| { + if labels.is_empty() { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("None yet."), + ) + } else { + this.child(h_flex().gap_1().children({ + let mut items = vec![]; + + for label in labels.iter() { + items.push( + Tag::secondary() + .outline() + .xsmall() + .child(SharedString::from(label)), + ); + } + + items + })) + } + }), + ) + .into_any_element() + } + + /// Files tab: the changed-files tree on the left, the diff of the + /// selected file on the right. + fn render_files_tab(&self, cx: &mut Context) -> AnyElement { + h_flex() + .flex_1() + .w_full() + .min_h_0() + .overflow_hidden() + .child(self.render_tree_column(cx)) + .child(self.render_detail_column(cx)) + .into_any_element() + } + + /// Full-height Commits tab: every commit of the patch series, or a + /// status message while loading / when there are none. + fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() .size_full() @@ -501,52 +797,103 @@ impl PullRequestDetailView { if let Some(error) = self.error.clone() { return placeholder(&error, cx); } - - let (title, author, picture, status, age, root_id, branch) = { - let store = self.store.read(cx); - let Some(root) = store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - else { - return placeholder("Pull request not found", cx); - }; - let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey); - ( - activity_subject(root), - profile.name(), - profile.picture(), - store.status_of(root), - relative_time(root.created_at), - root.id, - branch_name_of(root), - ) - }; - let current_commit = self.current_commit.clone(); + if self.commits.is_empty() { + return placeholder("No commits found", cx); + } v_flex() - .size_full() + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scrollbar() + .children( + self.commits + .iter() + .enumerate() + .map(|(ix, commit)| self.render_commit_row(ix, commit, cx)), + ) + .into_any_element() + } + + /// One row of the commits tab: id, summary, author and time. Clicking a + /// row opens the commit's diff in the bottom dock. + fn render_commit_row( + &self, + ix: usize, + commit: &FileCommit, + cx: &mut Context, + ) -> AnyElement { + let meta = commit_meta(commit); + let id = commit.id.clone(); + + h_flex() + .id(ix) + .px_4() + .py_2() + .gap_2() + .items_center() + .text_sm() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) .child( - v_flex() + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() .flex_1() - .min_h_0() - .px_4() - .py_3() - .gap_4() - .overflow_y_scrollbar() + .min_w_0() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ) + .when(!meta.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(meta)), + ) + }) + // Commits parsed from the nostr patch set may not exist in any + // local clone; only git-backed PRs open a diff viewer. + .when_some(self.worktree.clone(), |this, worktree| { + this.on_click(cx.listener(move |this, _event, window, cx| { + this.open_commit_diff(worktree.clone(), &id, window, cx); + })) + }) + .into_any_element() + } + + /// One comment card, same design as the issue panel: avatar, author, + /// "commented" and age on the header row, content below. + fn render_comments(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { + let store = self.store.read(cx); + let comments: Vec<&Event> = store.comments_of(id).collect(); + let title = SharedString::from(format!("Discussions {}", comments.len())); + + v_flex() + .gap_4() + .child(div().text_xs().font_semibold().child(title)) + .children(comments.iter().map(|comment| { + let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(comment.created_at); + + v_flex() + .gap_1() + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) .child( - // Title row: status badge + subject. h_flex() .gap_2() - .items_center() - .child(status_badge(status, cx)) - .child(div().font_semibold().child(title)), - ) - .child( - // Author, age, tip commit and branch. - h_flex() - .gap_2() - .items_center() .text_sm() .child( h_flex() @@ -560,135 +907,10 @@ impl PullRequestDetailView { ) .child(author), ) - .child(SharedString::from("opened")) .child( div() .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ) - .when_some(current_commit, |this, id| { - this.child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(id.clone()), - ) - .child(Clipboard::new("pr-commit").value(&id)) - }) - .when_some(branch, |this, branch| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(branch), - ) - }), - ) - .when(!self.description.is_empty(), |this| { - // Description, plain text for now. - this.child(div().text_sm().child(self.description.clone())) - }) - .child(self.render_commits(cx)) - .child(self.render_comments(&root_id, cx)), - ) - .child( - h_flex() - .px_4() - .py_3() - .border_t_1() - .border_color(cx.theme().border) - .child(self.render_form(&root_id, cx)), - ) - .into_any_element() - } - - /// The commits of the patch series: id, summary, author and time. - fn render_commits(&self, cx: &App) -> AnyElement { - if self.commits.is_empty() { - return div().into_any_element(); - } - - v_flex() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Commits"), - ) - .children(self.commits.iter().map(|commit| { - let meta = commit_meta(commit); - h_flex() - .gap_2() - .items_center() - .text_sm() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ) - .when(!meta.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(meta)), - ) - }) - })) - .into_any_element() - } - - fn render_comments(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { - let store = self.store.read(cx); - let comments: Vec<&Event> = store.comments_of(id).collect(); - - v_flex() - .gap_3() - .when(!comments.is_empty(), |this| { - this.child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("Comments ({})", comments.len()))), - ) - }) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .xsmall(), - ) - .child(author), + .child("commented"), ) .child( div() @@ -705,47 +927,134 @@ impl PullRequestDetailView { .into_any_element() } - fn render_form(&mut self, id: &EventId, _cx: &mut Context) -> AnyElement { + fn render_form(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { let comment_input = self.comment_input.clone(); let store = self.store.clone(); let id = id.to_owned(); v_flex() - .flex_1() - .min_w_0() .gap_2() - .child(Textarea::new(&self.comment_input).h(px(72.))) .child( - h_flex().justify_end().child( - Button::new("pr-comment") - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = store - .read(cx) - .pull_requests - .iter() - .find(|pr| pr.id == id) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), + Textarea::new(&self.comment_input) + .h_24() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().muted), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Markdown).small()) + .child("Markdown is supported"), + ) + .child( + Button::new("pr-comment") + .primary() + .label("Comment") + .tooltip("Post comment") + .on_click(move |_event, window, cx| { + let content = comment_input.read(cx).value().trim().to_string(); + if content.is_empty() { + return; + } + let Some(root) = store + .read(cx) + .pull_requests + .iter() + .find(|pr| pr.id == id) + .cloned() + else { + return; + }; + store.update(cx, |store, cx| { + store.comment(&root, content, cx); + }); + comment_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + }), + ), ) .into_any_element() } + + /// Always-visible header: status badge and title, like the issue panel. + fn render_header(&self, cx: &mut Context) -> AnyElement { + let current_commit = self.current_commit.clone(); + let (title, status, branch) = { + let store = self.store.read(cx); + let Some(root) = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + else { + return div().into_any_element(); + }; + ( + activity_subject(root), + store.status_of(root), + branch_name_of(root), + ) + }; + + v_flex() + .px_4() + .mb_4() + .child( + h_flex() + .w_full() + .min_h_16() + .gap_2() + .child(status_badge(status, cx)) + .child( + div() + .flex_1() + .min_w_0() + .font_semibold() + .line_height(relative(1.2)) + .child(title), + ), + ) + .child( + h_flex() + .gap_3() + .when_some(branch, |this, branch| { + this.child( + Button::new("branch") + .ghost() + .small() + .icon(CustomIconName::GitBranch) + .label(branch), + ) + }) + .when_some(current_commit, |this, id| { + this.child( + h_flex() + .gap_1() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(id.clone()) + .child(Clipboard::new("pr-commit").value(&id)), + ) + }), + ) + .into_any_element() + } +} + +/// One sidebar section title. +fn sidebar_title(text: &str, cx: &App) -> AnyElement { + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() } /// The `c` tag of a PR event (tip of the proposed branch), as hex. @@ -828,17 +1137,15 @@ impl BasePanel for PullRequestDetailView { } impl Panel for PullRequestDetailView { - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let subject = self - .store - .read(cx) - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - .map(activity_subject) - .unwrap_or_else(|| SharedString::from("Pull request")); - - div().text_sm().child(subject) + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let hex = self.pr_id.to_hex(); + let id = SharedString::from(&hex[..8]); + let title = if self.repo_name.is_empty() { + id + } else { + SharedString::from(format!("{}/{}", self.repo_name, id)) + }; + div().text_sm().child(title) } } @@ -852,31 +1159,23 @@ impl Focusable for PullRequestDetailView { impl Render for PullRequestDetailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_resizable("pull-request-detail") - .child( - resizable_panel() - .size(px(320.)) - .size_range(px(160.)..px(600.)) - .flex_none() - .bg(cx.theme().background) - .child(self.render_conversation(cx)), - ) - .child( - resizable_panel().child( - h_flex() - .size_full() - .min_h_0() - .bg(cx.theme().background) - .child(self.render_tree_column(cx)) - .child(self.render_detail_column(cx)), - ), - ) + v_flex() + .id("pull-request-detail") + .size_full() + .min_h_0() + .child(self.render_header(cx)) + .child(self.render_tabs(cx)) + .map(|this| match self.active_tab { + 0 => this.child(self.render_discussion(cx)), + 1 => this.child(self.render_files_tab(cx)), + _ => this.child(self.render_commits_tab(cx)), + }) } } #[cfg(test)] mod tests { - use nostr::prelude::*; + use nostr::prelude::{Tag, *}; use super::*; diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 07546d1..228747b 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -122,7 +122,15 @@ impl PullRequestsView { return; }; - let panel = cx.new(|cx| PullRequestDetailView::new(self.store.clone(), pr_id, window, cx)); + let panel = cx.new(|cx| { + PullRequestDetailView::new( + self.dock_area.clone(), + self.store.clone(), + pr_id, + window, + cx, + ) + }); dock_area.update(cx, |dock_area, cx| { dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);