update pull request detail panel
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-bottom-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/><path d="m9 10 3-3 3 3"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.75 14.5C6.33579 14.5 6 14.8358 6 15.25C6 15.6642 6.33579 16 6.75 16H17.25C17.6642 16 18 15.6642 18 15.25C18 14.8358 17.6642 14.5 17.25 14.5H6.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 448 B |
@@ -1 +1,3 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM4 16V19H20V16H4ZM4 14H20V5H4V14Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 15.25L6.75 15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 482 B |
@@ -391,6 +391,7 @@ impl CommitDiffView {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
|
.pb_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
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::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
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::avatar::Avatar;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::clipboard::Clipboard;
|
use gpui_component::clipboard::Clipboard;
|
||||||
use gpui_component::input::{Textarea, TextareaState};
|
use gpui_component::input::{Textarea, TextareaState};
|
||||||
use gpui_component::list::ListItem;
|
use gpui_component::list::ListItem;
|
||||||
use gpui_component::resizable::{resizable_panel, v_resizable};
|
|
||||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||||
use gpui_component::spinner::Spinner;
|
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::tree::{TreeEntry, TreeState, tree};
|
||||||
use gpui_component::{
|
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_core::{activity_subject, pull_request_patch};
|
||||||
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
|
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
|
||||||
use signed_state::{GitStore, ProfileStore, RepoStore};
|
use signed_state::{GitStore, ProfileStore, RepoStore};
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
|
use super::diff::CommitDiffView;
|
||||||
use super::helpers::{
|
use super::helpers::{
|
||||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
|
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
|
||||||
status_badge, tree_items, tree_row,
|
status_badge, tree_items, tree_row,
|
||||||
@@ -36,12 +40,19 @@ const TREE_WIDTH: f32 = 260.;
|
|||||||
/// Detail panel of a single pull request.
|
/// Detail panel of a single pull request.
|
||||||
pub struct PullRequestDetailView {
|
pub struct PullRequestDetailView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
/// Dock area new panels (commit diffs) are added to.
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
/// Repo store holding the PR, its status and comments.
|
/// Repo store holding the PR, its status and comments.
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
/// Event id of the root PR event (kind 1618; updates are revisions).
|
/// Event id of the root PR event (kind 1618; updates are revisions).
|
||||||
pr_id: EventId,
|
pr_id: EventId,
|
||||||
/// Input state of the "leave a comment" textarea.
|
/// Input state of the "leave a comment" textarea.
|
||||||
comment_input: Entity<TextareaState>,
|
comment_input: Entity<TextareaState>,
|
||||||
|
/// 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<PathBuf>,
|
||||||
/// Root PR's content, shown as plain text.
|
/// Root PR's content, shown as plain text.
|
||||||
description: SharedString,
|
description: SharedString,
|
||||||
/// Tip commit of the PR: the latest update's `c` tag, else the root's.
|
/// 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.
|
/// The patch is being parsed on a background task.
|
||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits.
|
||||||
|
active_tab: usize,
|
||||||
/// Changed-files explorer state.
|
/// Changed-files explorer state.
|
||||||
tree_state: Entity<TreeState>,
|
tree_state: Entity<TreeState>,
|
||||||
/// Path of the file whose diff is shown in the detail column.
|
/// Path of the file whose diff is shown in the detail column.
|
||||||
@@ -72,6 +85,7 @@ pub struct PullRequestDetailView {
|
|||||||
|
|
||||||
impl PullRequestDetailView {
|
impl PullRequestDetailView {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
pr_id: EventId,
|
pr_id: EventId,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -85,6 +99,19 @@ impl PullRequestDetailView {
|
|||||||
let comment_input =
|
let comment_input =
|
||||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…"));
|
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).
|
// Re-render when the store refreshes (new comments, status changes).
|
||||||
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
|
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
|
||||||
|
|
||||||
@@ -95,15 +122,19 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
dock_area,
|
||||||
store,
|
store,
|
||||||
pr_id,
|
pr_id,
|
||||||
comment_input,
|
comment_input,
|
||||||
|
repo_name,
|
||||||
|
worktree: None,
|
||||||
description: SharedString::default(),
|
description: SharedString::default(),
|
||||||
current_commit: None,
|
current_commit: None,
|
||||||
commits: Vec::new(),
|
commits: Vec::new(),
|
||||||
diff: None,
|
diff: None,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
|
active_tab: 0,
|
||||||
tree_state,
|
tree_state,
|
||||||
selected_file: None,
|
selected_file: None,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
@@ -200,7 +231,8 @@ impl PullRequestDetailView {
|
|||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||||
let workdir = repo
|
let workdir = repo
|
||||||
.workdir()
|
.workdir()
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?;
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
|
.to_path_buf();
|
||||||
let tip =
|
let tip =
|
||||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||||
let base = match base {
|
let base = match base {
|
||||||
@@ -215,23 +247,24 @@ impl PullRequestDetailView {
|
|||||||
repo.merge_base(tip_id, head)?.to_string()
|
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 =
|
let commits =
|
||||||
signed_git::worktree_commit_range_commits(workdir, &base, &tip)?;
|
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||||
Ok::<_, anyhow::Error>((diff, commits))
|
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (diff, commits) = match git {
|
let (diff, commits, worktree) = match git {
|
||||||
Some(Ok((diff, commits))) => (Ok(diff), commits),
|
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||||
Some(Err(error)) => (Err(error), Vec::new()),
|
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||||
None => (nostr_diff, nostr_commits),
|
None => (nostr_diff, nostr_commits, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.worktree = worktree;
|
||||||
this.current_commit = current_commit.map(SharedString::from);
|
this.current_commit = current_commit.map(SharedString::from);
|
||||||
this.commits = commits;
|
this.commits = commits;
|
||||||
match diff {
|
match diff {
|
||||||
@@ -293,6 +326,33 @@ impl PullRequestDetailView {
|
|||||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
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<Self>,
|
||||||
|
) {
|
||||||
|
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.
|
/// One row of the changed-files tree: icon + name, indented by depth.
|
||||||
fn render_tree_item(
|
fn render_tree_item(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
@@ -487,9 +547,245 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top panel: title, status, author, description, commits and comments,
|
/// Underline tab bar: Discussion, Files and Commits.
|
||||||
/// one scrollable column with the comment form pinned at the bottom.
|
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
fn render_conversation(&mut self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<PublicKey> = 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<String> = 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<Self>) -> 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<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -501,129 +797,45 @@ impl PullRequestDetailView {
|
|||||||
if let Some(error) = self.error.clone() {
|
if let Some(error) = self.error.clone() {
|
||||||
return placeholder(&error, cx);
|
return placeholder(&error, cx);
|
||||||
}
|
}
|
||||||
|
if self.commits.is_empty() {
|
||||||
|
return placeholder("No commits found", 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();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.px_4()
|
|
||||||
.py_3()
|
|
||||||
.gap_4()
|
|
||||||
.overflow_y_scrollbar()
|
.overflow_y_scrollbar()
|
||||||
.child(
|
.children(
|
||||||
// Title row: status badge + subject.
|
self.commits
|
||||||
h_flex()
|
.iter()
|
||||||
.gap_2()
|
.enumerate()
|
||||||
.items_center()
|
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)),
|
||||||
.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()
|
|
||||||
.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("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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The commits of the patch series: id, summary, author and time.
|
/// One row of the commits tab: id, summary, author and time. Clicking a
|
||||||
fn render_commits(&self, cx: &App) -> AnyElement {
|
/// row opens the commit's diff in the bottom dock.
|
||||||
if self.commits.is_empty() {
|
fn render_commit_row(
|
||||||
return div().into_any_element();
|
&self,
|
||||||
}
|
ix: usize,
|
||||||
|
commit: &FileCommit,
|
||||||
v_flex()
|
cx: &mut Context<Self>,
|
||||||
.gap_2()
|
) -> AnyElement {
|
||||||
.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);
|
let meta = commit_meta(commit);
|
||||||
|
let id = commit.id.clone();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.px_4()
|
||||||
|
.py_2()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.border_b(px(1.))
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_family(cx.theme().mono_font_family.clone())
|
.font_family(cx.theme().mono_font_family.clone())
|
||||||
@@ -647,25 +859,26 @@ impl PullRequestDetailView {
|
|||||||
.child(SharedString::from(meta)),
|
.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()
|
.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<Self>) -> AnyElement {
|
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let comments: Vec<&Event> = store.comments_of(id).collect();
|
let comments: Vec<&Event> = store.comments_of(id).collect();
|
||||||
|
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_4()
|
||||||
.when(!comments.is_empty(), |this| {
|
.child(div().text_xs().font_semibold().child(title))
|
||||||
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| {
|
.children(comments.iter().map(|comment| {
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
@@ -674,6 +887,10 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.p_3()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -686,10 +903,15 @@ impl PullRequestDetailView {
|
|||||||
.name(author.clone())
|
.name(author.clone())
|
||||||
.when_some(picture, |this, url| this.src(url))
|
.when_some(picture, |this, url| this.src(url))
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.xsmall(),
|
.small(),
|
||||||
)
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("commented"),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
@@ -705,18 +927,31 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_form(&mut self, id: &EventId, _cx: &mut Context<Self>) -> AnyElement {
|
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let comment_input = self.comment_input.clone();
|
let comment_input = self.comment_input.clone();
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let id = id.to_owned();
|
let id = id.to_owned();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(Textarea::new(&self.comment_input).h(px(72.)))
|
|
||||||
.child(
|
.child(
|
||||||
h_flex().justify_end().child(
|
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")
|
Button::new("pr-comment")
|
||||||
.primary()
|
.primary()
|
||||||
.label("Comment")
|
.label("Comment")
|
||||||
@@ -746,6 +981,80 @@ impl PullRequestDetailView {
|
|||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Always-visible header: status badge and title, like the issue panel.
|
||||||
|
fn render_header(&self, cx: &mut Context<Self>) -> 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.
|
/// 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 {
|
impl Panel for PullRequestDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let subject = self
|
let hex = self.pr_id.to_hex();
|
||||||
.store
|
let id = SharedString::from(&hex[..8]);
|
||||||
.read(cx)
|
let title = if self.repo_name.is_empty() {
|
||||||
.pull_requests
|
id
|
||||||
.iter()
|
} else {
|
||||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
SharedString::from(format!("{}/{}", self.repo_name, id))
|
||||||
.map(activity_subject)
|
};
|
||||||
.unwrap_or_else(|| SharedString::from("Pull request"));
|
div().text_sm().child(title)
|
||||||
|
|
||||||
div().text_sm().child(subject)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,31 +1159,23 @@ impl Focusable for PullRequestDetailView {
|
|||||||
|
|
||||||
impl Render for PullRequestDetailView {
|
impl Render for PullRequestDetailView {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_resizable("pull-request-detail")
|
v_flex()
|
||||||
.child(
|
.id("pull-request-detail")
|
||||||
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()
|
.size_full()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.bg(cx.theme().background)
|
.child(self.render_header(cx))
|
||||||
.child(self.render_tree_column(cx))
|
.child(self.render_tabs(cx))
|
||||||
.child(self.render_detail_column(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::{Tag, *};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,15 @@ impl PullRequestsView {
|
|||||||
return;
|
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.update(cx, |dock_area, cx| {
|
||||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||||
|
|||||||
Reference in New Issue
Block a user