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);