feat: out-of-box experience #2
@@ -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()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.child(
|
||||
|
||||
@@ -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<DockArea>,
|
||||
/// Repo store holding the PR, its status and comments.
|
||||
store: Entity<RepoStore>,
|
||||
/// 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<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.
|
||||
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<SharedString>,
|
||||
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// 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<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
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<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.
|
||||
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<Self>) -> AnyElement {
|
||||
/// Underline tab bar: Discussion, Files and Commits.
|
||||
fn render_tabs(&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 {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
@@ -501,129 +797,45 @@ impl PullRequestDetailView {
|
||||
if let Some(error) = self.error.clone() {
|
||||
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()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.gap_4()
|
||||
.overflow_y_scrollbar()
|
||||
.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()
|
||||
.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)),
|
||||
.children(
|
||||
self.commits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, commit)| self.render_commit_row(ix, commit, 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| {
|
||||
/// 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<Self>,
|
||||
) -> 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(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
@@ -647,25 +859,26 @@ impl PullRequestDetailView {
|
||||
.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<Self>) -> 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_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()))),
|
||||
)
|
||||
})
|
||||
.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();
|
||||
@@ -674,6 +887,10 @@ impl PullRequestDetailView {
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -686,10 +903,15 @@ impl PullRequestDetailView {
|
||||
.name(author.clone())
|
||||
.when_some(picture, |this, url| this.src(url))
|
||||
.rounded(cx.theme().radius)
|
||||
.xsmall(),
|
||||
.small(),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
@@ -705,18 +927,31 @@ impl PullRequestDetailView {
|
||||
.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 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(
|
||||
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")
|
||||
@@ -746,6 +981,80 @@ impl PullRequestDetailView {
|
||||
)
|
||||
.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.
|
||||
@@ -828,17 +1137,15 @@ impl BasePanel for PullRequestDetailView {
|
||||
}
|
||||
|
||||
impl Panel for PullRequestDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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()
|
||||
v_flex()
|
||||
.id("pull-request-detail")
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.bg(cx.theme().background)
|
||||
.child(self.render_tree_column(cx))
|
||||
.child(self.render_detail_column(cx)),
|
||||
),
|
||||
)
|
||||
.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::*;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user