feat: out-of-box experience #2

Merged
reya merged 64 commits from feat/ui into master 2026-08-25 13:23:08 +00:00
5 changed files with 558 additions and 246 deletions
Showing only changes of commit e34008775c - Show all commits
@@ -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

+3 -1
View File
@@ -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,52 +797,103 @@ 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() {
let (title, author, picture, status, age, root_id, branch) = { return placeholder("No commits found", 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 {
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() 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<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( .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() .flex_1()
.min_h_0() .min_w_0()
.px_4() .text_ellipsis()
.py_3() .whitespace_nowrap()
.gap_4() .child(commit.summary.clone()),
.overflow_y_scrollbar() )
.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<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_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( .child(
// Title row: status badge + subject.
h_flex() h_flex()
.gap_2() .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() .text_sm()
.child( .child(
h_flex() h_flex()
@@ -560,135 +907,10 @@ impl PullRequestDetailView {
) )
.child(author), .child(author),
) )
.child(SharedString::from("opened"))
.child( .child(
div() div()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)), .child("commented"),
)
.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<Self>) -> 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( .child(
div() div()
@@ -705,47 +927,134 @@ 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)
Button::new("pr-comment") .h_24()
.primary() .text_color(cx.theme().muted_foreground)
.label("Comment") .bg(cx.theme().muted),
.tooltip("Post comment") )
.on_click(move |_event, window, cx| { .child(
let content = comment_input.read(cx).value().trim().to_string(); h_flex()
if content.is_empty() { .justify_between()
return; .child(
} h_flex()
let Some(root) = store .gap_1()
.read(cx) .text_xs()
.pull_requests .text_color(cx.theme().muted_foreground)
.iter() .child(Icon::new(CustomIconName::Markdown).small())
.find(|pr| pr.id == id) .child("Markdown is supported"),
.cloned() )
else { .child(
return; Button::new("pr-comment")
}; .primary()
store.update(cx, |store, cx| { .label("Comment")
store.comment(&root, content, cx); .tooltip("Post comment")
}); .on_click(move |_event, window, cx| {
comment_input.update(cx, |input, cx| { let content = comment_input.read(cx).value().trim().to_string();
input.set_value("", window, cx); 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() .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_full()
.size(px(320.)) .min_h_0()
.size_range(px(160.)..px(600.)) .child(self.render_header(cx))
.flex_none() .child(self.render_tabs(cx))
.bg(cx.theme().background) .map(|this| match self.active_tab {
.child(self.render_conversation(cx)), 0 => this.child(self.render_discussion(cx)),
) 1 => this.child(self.render_files_tab(cx)),
.child( _ => this.child(self.render_commits_tab(cx)),
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)),
),
)
} }
} }
#[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);