diff --git a/Cargo.lock b/Cargo.lock
index 764ce2a..37ce9d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7902,6 +7902,7 @@ name = "signed_state"
version = "1.0.0"
dependencies = [
"anyhow",
+ "bitcoin_hashes 1.2.0",
"flume 0.11.1",
"gpui",
"log",
diff --git a/crates/assets/assets/icons/git-pull-request-closed.svg b/crates/assets/assets/icons/git-pull-request-closed.svg
new file mode 100644
index 0000000..dc3def8
--- /dev/null
+++ b/crates/assets/assets/icons/git-pull-request-closed.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/assets/assets/icons/git-pull-request-draft.svg b/crates/assets/assets/icons/git-pull-request-draft.svg
new file mode 100644
index 0000000..26b93ae
--- /dev/null
+++ b/crates/assets/assets/icons/git-pull-request-draft.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/assets/assets/icons/git-pull-request-merged.svg b/crates/assets/assets/icons/git-pull-request-merged.svg
new file mode 100644
index 0000000..cb3e92b
--- /dev/null
+++ b/crates/assets/assets/icons/git-pull-request-merged.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/assets/assets/icons/git-pull-request.svg b/crates/assets/assets/icons/git-pull-request.svg
new file mode 100644
index 0000000..404abd6
--- /dev/null
+++ b/crates/assets/assets/icons/git-pull-request.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs
index 959bccf..751b9f4 100644
--- a/crates/assets/src/lib.rs
+++ b/crates/assets/src/lib.rs
@@ -55,6 +55,10 @@ pub enum CustomIconName {
GitIssueOpen,
GitIssueClosed,
GitIssueOngoing,
+ GitPullRequest,
+ GitPullRequestClosed,
+ GitPullRequestDraft,
+ GitPullRequestMerged,
GitClone,
GitBranch,
Tag,
@@ -70,6 +74,10 @@ impl IconNamed for CustomIconName {
CustomIconName::GitIssueOpen => "icons/git-issue-open.svg",
CustomIconName::GitIssueClosed => "icons/git-issue-close.svg",
CustomIconName::GitIssueOngoing => "icons/git-issue-ongoing.svg",
+ CustomIconName::GitPullRequest => "icons/git-pull-request.svg",
+ CustomIconName::GitPullRequestClosed => "icons/git-pull-request-closed.svg",
+ CustomIconName::GitPullRequestDraft => "icons/git-pull-request-draft.svg",
+ CustomIconName::GitPullRequestMerged => "icons/git-pull-request-merged.svg",
CustomIconName::GitClone => "icons/git-clone.svg",
CustomIconName::GitBranch => "icons/git-branch.svg",
CustomIconName::Tag => "icons/tag.svg",
diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml
index 5192a08..0bd56f0 100644
--- a/crates/signed_state/Cargo.toml
+++ b/crates/signed_state/Cargo.toml
@@ -14,6 +14,8 @@ nostr.workspace = true
nostr-sdk.workspace = true
nostr-connect.workspace = true
+bitcoin_hashes = "1"
+
gpui.workspace = true
flume.workspace = true
anyhow.workspace = true
diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs
index 8a06b37..a4eec77 100644
--- a/crates/signed_state/src/repo.rs
+++ b/crates/signed_state/src/repo.rs
@@ -297,6 +297,39 @@ impl RepoStore {
self.send(builder, cx);
}
+ /// Open a pull request on this repository: a root PR event whose content
+ /// is the `git format-patch` output of the proposed changes.
+ ///
+ /// The branch metadata (branch name, clone URL, merge base, root patch)
+ /// isn't known to the UI yet and is left empty; the proposed commit is
+ /// parsed from the patch's `From ` header, falling back to an
+ /// empty hash for hand-written content.
+ pub fn open_pull_request(
+ &mut self,
+ subject: Option,
+ content: String,
+ cx: &mut Context,
+ ) {
+ let current_commit = patch_current_commit(&content)
+ .and_then(|hex| hex.parse().ok())
+ .unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20]));
+
+ let builder = GitPullRequest {
+ repository: self.addr.clone(),
+ content,
+ subject,
+ labels: Vec::new(),
+ branch_name: None,
+ clone: Vec::new(),
+ current_commit,
+ root_patch_event: None,
+ merge_base: None,
+ }
+ .into_event_builder();
+
+ self.send(builder, cx);
+ }
+
/// Send a root patch (`git format-patch` output) to this repository.
pub fn send_root_patch(&mut self, patch: String, cx: &mut Context) {
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
@@ -356,3 +389,32 @@ where
fn sort_newest_first(events: &mut [Event]) {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
}
+
+/// The proposed commit of a `git format-patch` output: the `From `
+/// header on its first line.
+fn patch_current_commit(patch: &str) -> Option<&str> {
+ let line = patch.lines().next()?;
+ let hex = line.strip_prefix("From ")?;
+ hex.split_whitespace().next().filter(|hex| hex.len() == 40)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::patch_current_commit;
+
+ #[test]
+ fn parses_format_patch_header() {
+ let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n";
+ assert_eq!(
+ patch_current_commit(patch),
+ Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a")
+ );
+ }
+
+ #[test]
+ fn no_commit_without_header() {
+ assert_eq!(patch_current_commit(""), None);
+ assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None);
+ assert_eq!(patch_current_commit("From short\n"), None);
+ }
+}
diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs
index ef093b8..a846f5f 100644
--- a/crates/workspace/src/image_cache.rs
+++ b/crates/workspace/src/image_cache.rs
@@ -1,23 +1,3 @@
-//! The shared image cache used by every `img` element in the app.
-//!
-//! Without an explicit cache, images (the profile pictures shown by
-//! `Avatar`s) fall back to the window's asset cache and are retained for the
-//! lifetime of the window: every avatar that was ever shown stays decoded in
-//! memory. This module installs one bounded LRU cache that the app controls
-//! instead:
-//!
-//! * The cache holds at most [`MAX_IMAGES`] entries; loading a new image
-//! evicts the least recently used one. Entries remember the [`Resource`]
-//! they were loaded from, so eviction drops the image from the sprite
-//! atlas *and* removes the asset from the asset system — freeing the raw
-//! fetched bytes alongside the decoded image.
-//! * [`clear_on_release`] drops the whole cache when an image-heavy view
-//! (`RepoDetailView`, `IssuesView`) is released, i.e. its panel closes.
-//! * [`clear`] drops everything on demand from anywhere in the app.
-//!
-//! Images that get cleared are re-fetched and re-decoded the next time they
-//! are rendered, so clearing trades a little bandwidth/CPU for memory.
-
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use std::sync::Arc;
diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs
index 7f252aa..5beb9e7 100644
--- a/crates/workspace/src/views/repo_detail/mod.rs
+++ b/crates/workspace/src/views/repo_detail/mod.rs
@@ -33,6 +33,7 @@ mod commits;
mod diff;
mod helpers;
mod issues;
+mod pull_requests;
use browser::{
CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES,
@@ -42,6 +43,7 @@ use commits::COMMIT_ROW_HEIGHT;
use diff::CommitDiffView;
use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items};
use issues::IssuesView;
+use pull_requests::PullRequestsView;
/// What kind of ref the header selectors switch to.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -667,10 +669,18 @@ impl RepoDetailView {
});
}
- /// Open the pull request detail view (not implemented yet).
- fn open_pull_request_detail(&mut self, _window: &mut Window, _cx: &mut Context) {
- // TODO: open a per-PR detail view in the dock area, like
- // [`Self::open_commit_diff`].
+ /// Open the pull requests panel at the bottom of the dock area.
+ fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(dock_area) = self.dock_area.upgrade() else {
+ return;
+ };
+
+ let panel = cx
+ .new(|cx| PullRequestsView::new(self.store.clone(), self.display_name(cx), window, cx));
+
+ dock_area.update(cx, |dock_area, cx| {
+ dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx);
+ });
}
/// Check out `name` (a branch or tag picked in the header) and refresh
diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs
new file mode 100644
index 0000000..2da897a
--- /dev/null
+++ b/crates/workspace/src/views/repo_detail/pull_requests.rs
@@ -0,0 +1,470 @@
+//! Pull requests panel: a bottom panel listing every pull request of the
+//! repository with its title, event id, author, age and status, filterable
+//! by status via the header's All/Open/Closed/Draft/Merged filter.
+
+use std::rc::Rc;
+
+use assets::CustomIconName;
+use gpui::prelude::*;
+use gpui::{
+ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
+ SharedString, Size, Window, div, px, size,
+};
+use gpui_component::avatar::Avatar;
+use gpui_component::button::{Button, ButtonVariants};
+use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
+use gpui_component::dock::{Panel, PanelEvent};
+use gpui_component::form::{field, v_form};
+use gpui_component::input::{Input, InputState, Textarea, TextareaState};
+use gpui_component::scroll::Scrollbar;
+use gpui_component::tooltip::Tooltip;
+use gpui_component::{
+ ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle,
+ WindowExt, h_flex, v_flex, v_virtual_list,
+};
+use nostr::prelude::{Event, Kind};
+use signed_core::{RepoStatus, activity_subject};
+use signed_state::{ProfileStore, RepoStore};
+use utils::relative_time;
+
+use super::helpers::placeholder;
+
+/// Height of one pull request row in the virtual list: same layout as an
+/// issue row (12px padding on top and bottom, a 14px title line and a 24px
+/// meta line), so the row totals ~71px.
+const PR_ROW_HEIGHT: f32 = 71.;
+
+/// Status filter of the pull request list, chosen via the header's filter
+/// buttons.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PullRequestFilter {
+ /// Every pull request, regardless of status.
+ All,
+ /// Pull requests whose resolved status is [`RepoStatus::Open`].
+ Open,
+ /// Pull requests whose resolved status is [`RepoStatus::Closed`].
+ Closed,
+ /// Pull requests whose resolved status is [`RepoStatus::Draft`].
+ Draft,
+ /// Pull requests whose resolved status is [`RepoStatus::Applied`]
+ /// (i.e. merged).
+ Merged,
+}
+
+impl PullRequestFilter {
+ /// Whether `pr` (of `store`) is included by this filter.
+ fn matches(self, store: &RepoStore, pr: &Event) -> bool {
+ match self {
+ Self::All => true,
+ Self::Open => store.status_of(pr) == RepoStatus::Open,
+ Self::Closed => store.status_of(pr) == RepoStatus::Closed,
+ Self::Draft => store.status_of(pr) == RepoStatus::Draft,
+ Self::Merged => store.status_of(pr) == RepoStatus::Applied,
+ }
+ }
+}
+
+pub struct PullRequestsView {
+ focus_handle: FocusHandle,
+ /// Repo store holding the pull requests and their statuses.
+ store: Entity,
+ /// Display name of the repository, for the panel title.
+ repo_name: SharedString,
+ /// Filter selected in the header filter buttons.
+ filter: PullRequestFilter,
+ /// Per-row heights of the virtual list.
+ item_sizes: Rc>>,
+ /// Number of rows [`Self::item_sizes`] was built for (the filtered
+ /// pull request count); rebuilt on change.
+ pr_len: usize,
+ /// Indices into the store's `pull_requests` matching [`Self::filter`]
+ /// (root PR events only; updates are revisions of the root and are not
+ /// listed separately), rebuilt every render; the virtual list renders
+ /// this slice.
+ visible_prs: Vec,
+ /// Virtual list state of the pull requests list.
+ scroll_handle: VirtualListScrollHandle,
+}
+
+impl PullRequestsView {
+ pub fn new(
+ store: Entity,
+ repo_name: SharedString,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ // PR author avatars stay in the shared cache until the panel
+ // closes; free them then.
+ crate::image_cache::clear_on_release(&cx.entity(), window, cx);
+
+ Self {
+ focus_handle: cx.focus_handle(),
+ store,
+ repo_name,
+ filter: PullRequestFilter::Open,
+ item_sizes: Rc::new(Vec::new()),
+ pr_len: 0,
+ visible_prs: Vec::new(),
+ scroll_handle: VirtualListScrollHandle::new(),
+ }
+ }
+
+ fn render_row(&self, ix: usize, pr: &Event, cx: &App) -> AnyElement {
+ let title = activity_subject(pr);
+ let id_hex = pr.id.to_hex();
+ let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey);
+ let author = profile.name();
+ let picture = profile.picture();
+ let age = relative_time(pr.created_at);
+ let status = self.store.read(cx).status_of(pr);
+
+ h_flex()
+ .id(ix)
+ .h(px(PR_ROW_HEIGHT))
+ .w_full()
+ .gap_4()
+ .p_3()
+ .border_b_1()
+ .border_color(cx.theme().border)
+ .items_start()
+ .child(Self::render_status(status, cx))
+ .child(
+ v_flex()
+ .flex_1()
+ .child(
+ div()
+ .min_w_0()
+ .text_ellipsis()
+ .whitespace_nowrap()
+ .line_clamp(1)
+ .text_sm()
+ .child(title),
+ )
+ .child(
+ h_flex()
+ .gap_2()
+ .text_xs()
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Avatar::new()
+ .name(author.clone())
+ .when_some(picture, |this, url| this.src(url))
+ .xsmall(),
+ )
+ .child(div().child(author)),
+ )
+ .child(SharedString::from("opened"))
+ .child(
+ div()
+ .text_color(cx.theme().muted_foreground)
+ .child(SharedString::from(&id_hex[..8])),
+ )
+ .child(div().child(age)),
+ ),
+ )
+ .into_any_element()
+ }
+
+ fn render_status(status: RepoStatus, cx: &App) -> AnyElement {
+ let (icon, label, tooltip, bg, fg) = match status {
+ RepoStatus::Open => (
+ CustomIconName::GitPullRequest,
+ "open",
+ "Pull request is open",
+ cx.theme().secondary,
+ cx.theme().secondary_foreground,
+ ),
+ RepoStatus::Closed => (
+ CustomIconName::GitPullRequestClosed,
+ "closed",
+ "Pull request is closed",
+ cx.theme().warning,
+ cx.theme().warning_foreground,
+ ),
+ RepoStatus::Draft => (
+ CustomIconName::GitPullRequestDraft,
+ "draft",
+ "Pull request is a draft",
+ cx.theme().accent,
+ cx.theme().accent_foreground,
+ ),
+ RepoStatus::Applied => (
+ CustomIconName::GitPullRequestMerged,
+ "merged",
+ "Pull request is merged",
+ cx.theme().primary,
+ cx.theme().primary_foreground,
+ ),
+ };
+
+ v_flex()
+ .id(label)
+ .flex_shrink_0()
+ .size_6()
+ .items_center()
+ .justify_center()
+ .rounded(cx.theme().radius)
+ .bg(bg)
+ .child(Icon::new(icon).xsmall().text_color(fg))
+ .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
+ .into_any_element()
+ }
+
+ fn render_header(&self, cx: &mut Context) -> AnyElement {
+ h_flex()
+ .w_full()
+ .items_center()
+ .gap_3()
+ .px_3()
+ .pb_2()
+ .border_b_1()
+ .border_color(cx.theme().border)
+ .child(
+ div()
+ .text_sm()
+ .text_color(cx.theme().muted_foreground)
+ .font_semibold()
+ .child("Pull Requests"),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Button::new("all")
+ .icon(CustomIconName::GitPullRequest)
+ .label("All")
+ .ghost()
+ .selected(self.filter == PullRequestFilter::All)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.filter = PullRequestFilter::All;
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("open")
+ .icon(CustomIconName::GitPullRequest)
+ .label("Open")
+ .ghost()
+ .selected(self.filter == PullRequestFilter::Open)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.filter = PullRequestFilter::Open;
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("closed")
+ .icon(CustomIconName::GitPullRequestClosed)
+ .label("Closed")
+ .ghost()
+ .selected(self.filter == PullRequestFilter::Closed)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.filter = PullRequestFilter::Closed;
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("draft")
+ .icon(CustomIconName::GitPullRequestDraft)
+ .label("Draft")
+ .ghost()
+ .selected(self.filter == PullRequestFilter::Draft)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.filter = PullRequestFilter::Draft;
+ cx.notify();
+ })),
+ )
+ .child(
+ Button::new("merged")
+ .icon(CustomIconName::GitPullRequestMerged)
+ .label("Merged")
+ .ghost()
+ .selected(self.filter == PullRequestFilter::Merged)
+ .on_click(cx.listener(|this, _event, _window, cx| {
+ this.filter = PullRequestFilter::Merged;
+ cx.notify();
+ })),
+ ),
+ )
+ // Spacer: pushes the button to the right edge.
+ .child(div().flex_1())
+ .child(
+ Button::new("new-pr")
+ .icon(IconName::Plus)
+ .label("New pull request")
+ .primary()
+ .on_click(cx.listener(|this, _event, window, cx| {
+ open_new_pull_request_dialog(this.store.clone(), window, cx);
+ })),
+ )
+ .into_any_element()
+ }
+}
+
+/// Open the "new pull request" dialog: a title and a patch input that
+/// submit through [`RepoStore::open_pull_request`] when confirmed.
+fn open_new_pull_request_dialog(store: Entity, window: &mut Window, cx: &mut App) {
+ let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
+ let patch =
+ cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…"));
+
+ window.open_dialog(cx, move |dialog, _window, _cx| {
+ let subject = subject.clone();
+ let patch = patch.clone();
+ let store = store.clone();
+
+ dialog
+ .width(px(520.))
+ .margin_top(px(50.))
+ .content(move |body, _window, _cx| {
+ body.child(
+ DialogHeader::new()
+ .child(DialogTitle::new().child("New pull request"))
+ .child(
+ DialogDescription::new()
+ .child("Propose a change with the output of `git format-patch`."),
+ ),
+ )
+ .child(
+ v_form()
+ .child(
+ field()
+ .label("Title")
+ .required(true)
+ .child(Input::new(&subject)),
+ )
+ .child(
+ field()
+ .label("Patch")
+ .child(Textarea::new(&patch).h(px(160.))),
+ ),
+ )
+ .child(
+ DialogFooter::new().justify_end().child(
+ Button::new("submit")
+ .primary()
+ .label("Create pull request")
+ .tooltip("Create pull request")
+ .on_click({
+ let subject = subject.clone();
+ let patch = patch.clone();
+ let store = store.clone();
+
+ move |_event, window, cx| {
+ let subject = subject.read(cx).value().to_string();
+ let patch = patch.read(cx).value().to_string();
+ let subject = (!subject.is_empty()).then_some(subject);
+
+ store.update(cx, |store, cx| {
+ store.open_pull_request(subject, patch, cx);
+ });
+
+ window.close_dialog(cx);
+ }
+ }),
+ ),
+ )
+ })
+ });
+}
+
+impl Panel for PullRequestsView {
+ fn panel_name(&self) -> &'static str {
+ "pull-requests"
+ }
+
+ fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+ div().child(SharedString::from(format!(
+ "{}/pull-requests",
+ self.repo_name
+ )))
+ }
+}
+
+impl EventEmitter for PullRequestsView {}
+
+impl Focusable for PullRequestsView {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for PullRequestsView {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let filter = self.filter;
+
+ // Indices of the root pull requests matching the active filter
+ // (updates are revisions of the root and are not listed
+ // separately); the virtual list renders this filtered slice.
+ self.visible_prs = {
+ let store = self.store.read(cx);
+ store
+ .pull_requests
+ .iter()
+ .enumerate()
+ .filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr))
+ .map(|(ix, _)| ix)
+ .collect()
+ };
+
+ let count = self.visible_prs.len();
+
+ // The virtual list's item count comes from `item_sizes`; rebuild it
+ // whenever the filtered pull request count changes.
+ if count != self.pr_len {
+ self.pr_len = count;
+ self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
+ }
+
+ let sizes = self.item_sizes.clone();
+ let scroll_handle = self.scroll_handle.clone();
+ let view = cx.entity().clone();
+
+ v_flex()
+ .size_full()
+ .child(self.render_header(cx))
+ .child(
+ v_flex()
+ .relative()
+ .flex_1()
+ .min_h_0()
+ .w_full()
+ .when(count > 0, |this| {
+ this.child(
+ v_virtual_list(view, "prl", sizes, move |this, range, _window, cx| {
+ let prs = &this.store.read(cx).pull_requests;
+ range
+ .map(|ix| {
+ let pr_ix = this.visible_prs[ix];
+ this.render_row(pr_ix, &prs[pr_ix], cx)
+ })
+ .collect()
+ })
+ .track_scroll(&scroll_handle)
+ .size_full(),
+ )
+ .child(
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .right_0()
+ .bottom_0()
+ .child(Scrollbar::vertical(&scroll_handle)),
+ )
+ })
+ .when(count == 0, |this| {
+ let message = match filter {
+ PullRequestFilter::All => "No pull requests",
+ PullRequestFilter::Open => "No open pull requests",
+ PullRequestFilter::Closed => "No closed pull requests",
+ PullRequestFilter::Draft => "No draft pull requests",
+ PullRequestFilter::Merged => "No merged pull requests",
+ };
+ this.child(placeholder(message, cx))
+ }),
+ )
+ .into_any_element()
+ }
+}