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()
.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,52 +797,103 @@ impl PullRequestDetailView {
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
let (title, author, picture, status, age, root_id, branch) = {
let store = self.store.read(cx);
let Some(root) = store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
else {
return placeholder("Pull request not found", cx);
};
let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey);
(
activity_subject(root),
profile.name(),
profile.picture(),
store.status_of(root),
relative_time(root.created_at),
root.id,
branch_name_of(root),
)
};
let current_commit = self.current_commit.clone();
if self.commits.is_empty() {
return placeholder("No commits found", cx);
}
v_flex()
.size_full()
.flex_1()
.w_full()
.min_h_0()
.overflow_y_scrollbar()
.children(
self.commits
.iter()
.enumerate()
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)),
)
.into_any_element()
}
/// One row of the commits tab: id, summary, author and time. Clicking a
/// row opens the commit's diff in the bottom dock.
fn render_commit_row(
&self,
ix: usize,
commit: &FileCommit,
cx: &mut Context<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(
v_flex()
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_h_0()
.px_4()
.py_3()
.gap_4()
.overflow_y_scrollbar()
.min_w_0()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.when(!meta.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(meta)),
)
})
// Commits parsed from the nostr patch set may not exist in any
// local clone; only git-backed PRs open a diff viewer.
.when_some(self.worktree.clone(), |this, worktree| {
this.on_click(cx.listener(move |this, _event, window, cx| {
this.open_commit_diff(worktree.clone(), &id, window, cx);
}))
})
.into_any_element()
}
/// One comment card, same design as the issue panel: avatar, author,
/// "commented" and age on the header row, content below.
fn render_comments(&mut self, id: &EventId, cx: &mut Context<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(
// Title row: status badge + subject.
h_flex()
.gap_2()
.items_center()
.child(status_badge(status, cx))
.child(div().font_semibold().child(title)),
)
.child(
// Author, age, tip commit and branch.
h_flex()
.gap_2()
.items_center()
.text_sm()
.child(
h_flex()
@@ -560,135 +907,10 @@ impl PullRequestDetailView {
)
.child(author),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
)
.when_some(current_commit, |this, id| {
this.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(id.clone()),
)
.child(Clipboard::new("pr-commit").value(&id))
})
.when_some(branch, |this, branch| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(branch),
)
}),
)
.when(!self.description.is_empty(), |this| {
// Description, plain text for now.
this.child(div().text_sm().child(self.description.clone()))
})
.child(self.render_commits(cx))
.child(self.render_comments(&root_id, cx)),
)
.child(
h_flex()
.px_4()
.py_3()
.border_t_1()
.border_color(cx.theme().border)
.child(self.render_form(&root_id, cx)),
)
.into_any_element()
}
/// The commits of the patch series: id, summary, author and time.
fn render_commits(&self, cx: &App) -> AnyElement {
if self.commits.is_empty() {
return div().into_any_element();
}
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Commits"),
)
.children(self.commits.iter().map(|commit| {
let meta = commit_meta(commit);
h_flex()
.gap_2()
.items_center()
.text_sm()
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.when(!meta.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(meta)),
)
})
}))
.into_any_element()
}
fn render_comments(&mut self, id: &EventId, cx: &mut Context<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("commented"),
)
.child(
div()
@@ -705,47 +927,134 @@ 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(
Button::new("pr-comment")
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = store
.read(cx)
.pull_requests
.iter()
.find(|pr| pr.id == id)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
Textarea::new(&self.comment_input)
.h_24()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().muted),
)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Markdown).small())
.child("Markdown is supported"),
)
.child(
Button::new("pr-comment")
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = store
.read(cx)
.pull_requests
.iter()
.find(|pr| pr.id == id)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
/// Always-visible header: status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<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()
.size_full()
.min_h_0()
.bg(cx.theme().background)
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx)),
),
)
v_flex()
.id("pull-request-detail")
.size_full()
.min_h_0()
.child(self.render_header(cx))
.child(self.render_tabs(cx))
.map(|this| match self.active_tab {
0 => this.child(self.render_discussion(cx)),
1 => this.child(self.render_files_tab(cx)),
_ => this.child(self.render_commits_tab(cx)),
})
}
}
#[cfg(test)]
mod tests {
use nostr::prelude::*;
use nostr::prelude::{Tag, *};
use super::*;
@@ -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);