feat: pull request and patch (#13)

Reviewed-on: https://git.reya.su/reya/signed/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2026-09-02 10:49:30 +00:00
parent 92afc5941e
commit 33cbe42551
24 changed files with 3023 additions and 454 deletions
+1
View File
@@ -3,5 +3,6 @@ mod repo_list;
pub(crate) mod sidebar;
pub use repo_detail::RepoDetailView;
pub(crate) use repo_detail::open_repo_panel;
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
@@ -60,6 +60,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if let Some(euc) = &announcement.euc {
rows.push(row(
"Earliest Commit",
@@ -67,13 +68,11 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if let Some(upstream) = &announcement.upstream {
rows.push(row(
"Upstream",
text(SharedString::from(upstream.clone())),
cx,
));
rows.push(row("Upstream", text(upstream.display()), cx));
}
if !announcement.hashtags.is_empty() {
rows.push(row(
"Hashtags",
@@ -81,6 +80,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.clone.is_empty() {
rows.push(row(
"Clone URLs",
@@ -92,6 +92,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.relays.is_empty() {
rows.push(row(
"Grasp Relays",
@@ -103,6 +104,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.maintainers.is_empty() {
rows.push(row(
"Maintainers",
@@ -1,5 +1,5 @@
use gpui::prelude::*;
use gpui::{AnyElement, App, Context, WeakEntity, div, px};
use gpui::{AnyElement, App, Context, Window, div, px};
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
@@ -12,17 +12,12 @@ use super::RepoDetailView;
/// Height of one commit row in the virtual list.
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
/// One row of the commit list: id, summary, author and relative time.
/// Clicking a row opens the diff of that commit in a new panel.
fn commit_row(
pub(super) fn commit_row(
ix: usize,
commit: &FileCommit,
view: &WeakEntity<RepoDetailView>,
on_click: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> AnyElement {
let view = view.clone();
let id = commit.id.clone();
h_flex()
.id(ix)
.px_4()
@@ -70,11 +65,7 @@ fn commit_row(
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx));
}
})
.on_click(move |_event, window, cx| on_click(window, cx))
.into_any_element()
}
@@ -114,22 +105,34 @@ impl RepoDetailView {
.w_full()
.min_h_0()
.child(
v_virtual_list(
view,
"repo-commits",
sizes,
move |this, range, _window, cx| {
let commits = this
.all_commits
.as_ref()
.map(|list| list.commits.as_slice())
.unwrap_or(&[]);
let view = cx.entity().downgrade();
range
.map(|ix| commit_row(ix, &commits[ix], &view, cx))
.collect()
},
)
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
let view = cx.entity().downgrade();
let commits = this
.all_commits
.as_ref()
.map(|list| list.commits.as_slice())
.unwrap_or(&[]);
range
.map(|ix| {
let id = commits[ix].id.clone();
let view = view.clone();
commit_row(
ix,
&commits[ix],
move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx)
});
}
},
cx,
)
})
.collect()
})
.track_scroll(&scroll_handle)
.size_full(),
)
+180 -135
View File
@@ -28,22 +28,13 @@ use super::helpers::{
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Detail panel showing the diff of one commit.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit,
/// Loaded diff; `None` while loading or after a failure.
/// The tree + per-file diff body shared by the commit diff panel and the
/// compare view of the new-pull-request panel. Owns the changed-files
/// explorer and the virtual list of the selected file's hunks; the host
/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
pub struct DiffPane {
/// Loaded diff; `None` until [`Self::set_diff`] is called.
diff: Option<CommitDiff>,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
@@ -55,114 +46,60 @@ pub struct CommitDiffView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
impl DiffPane {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
diff: None,
loading: true,
error: None,
tree_state,
tree_state: cx.new(|cx| TreeState::new(cx)),
selected_file: None,
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(),
}
}
/// Load the commit diff (and the full commit metadata) on a background
/// task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
/// The loaded diff, for stats and badges in the host's header.
pub fn diff(&self) -> Option<&CommitDiff> {
self.diff.as_ref()
}
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
this.selected_file = first.clone();
this.diff = Some(diff);
if let Some(path) = first {
this.set_diff_rows(path.as_ref());
}
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
/// Replace the diff and rebuild the tree and the selected file's rows.
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
self.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
self.selected_file = first.clone();
self.diff = Some(diff);
if let Some(path) = first {
self.set_diff_rows(path.as_ref());
}
}
self.tasks.push(task);
/// Forget the diff (e.g. when the compared branches changed): clear the
/// tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None;
self.selected_file = None;
self.rows = Vec::new();
self.item_sizes = Rc::new(Vec::new());
self.tree_state.update(cx, |state, cx| {
state.set_items(Vec::new(), cx);
});
}
/// Show the diff of the file at `path` (selected in the tree).
@@ -226,8 +163,8 @@ impl CommitDiffView {
.p_2(),
)
})
.when(self.diff.is_none() && !self.loading, |this| {
this.child(placeholder("Failed to load diff", cx))
.when(self.diff.is_none(), |this| {
this.child(placeholder("No changes", cx))
}),
)
.into_any_element()
@@ -235,23 +172,12 @@ impl CommitDiffView {
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&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 Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx);
return placeholder("No changes", cx);
};
let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() {
placeholder("No files changed in this commit", cx)
placeholder("No files changed", cx)
} else {
placeholder("Select a file", cx)
};
@@ -377,11 +303,126 @@ impl CommitDiffView {
.child(div().id("commit-diff-body").flex_1().min_h_0().child(body))
.into_any_element()
}
}
impl Render for DiffPane {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.min_h_0()
.bg(cx.theme().background)
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx))
}
}
/// Detail panel showing the diff of one commit: a metadata header plus the
/// shared [`DiffPane`] body.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer and per-file diff, shared with the compare
/// view of the new-pull-request panel.
pane: Entity<DiffPane>,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let pane = cx.new(DiffPane::new);
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
loading: true,
error: None,
pane,
tasks: Vec::new(),
}
}
/// Load the commit diff (and the full commit metadata) on a background
/// task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => {
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Header: commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| {
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
(
diff.files.len(),
diff.files.iter().map(|file| file.insertions).sum(),
@@ -481,6 +522,19 @@ impl Focusable for CommitDiffView {
impl Render for CommitDiffView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let body: AnyElement = if self.loading {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
} else if let Some(error) = self.error.clone() {
placeholder(&error, cx)
} else {
self.pane.clone().into_any_element()
};
v_resizable("commit-diff")
.child(
resizable_panel()
@@ -490,15 +544,6 @@ impl Render for CommitDiffView {
.bg(cx.theme().background)
.child(self.render_header(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)),
),
)
.child(resizable_panel().child(body))
}
}
@@ -21,16 +21,13 @@ use utils::relative_time;
/// Detail panel of a single issue.
pub struct IssueDetailView {
focus_handle: FocusHandle,
/// Repo store holding the issues and their statuses.
store: Entity<RepoStore>,
issue_id: EventId,
contents: HashMap<EventId, SharedString>,
/// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>,
/// Issue/comment bodies as shared strings, keyed by event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
focus_handle: FocusHandle,
}
impl IssueDetailView {
@@ -283,7 +280,13 @@ impl Render for IssueDetailView {
let content = self
.contents
.entry(issue.id)
.or_insert_with(|| SharedString::from(issue.content.clone()))
.or_insert_with(|| {
if issue.content.is_empty() {
SharedString::from("No description provided.")
} else {
SharedString::from(&issue.content)
}
})
.clone();
(
@@ -338,8 +341,7 @@ impl Render for IssueDetailView {
h_flex()
.gap_1()
.child(
UserAvatar::new(author.clone())
.picture(picture),
UserAvatar::new(&author).picture(picture),
)
.child(author),
)
+180 -6
View File
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use anyhow::Error;
use assets::CustomIconName;
@@ -26,9 +27,9 @@ use gpui_component::{
VirtualListScrollHandle, h_flex, v_flex,
};
use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_core::{Announcement, RepoAddr, filters};
use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
@@ -40,8 +41,10 @@ mod helpers;
mod init_dialog;
mod issue_detail;
mod issues;
mod new_pull_request;
mod pull_request_detail;
mod pull_requests;
mod send_patch;
use about::open_about_dialog;
use browser::{
@@ -52,7 +55,10 @@ use commits::COMMIT_ROW_HEIGHT;
use diff::CommitDiffView;
use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items};
use issues::{IssuesView, open_new_issue_dialog};
use pull_requests::{PullRequestsView, open_new_pull_request_dialog};
use pull_requests::PullRequestsView;
use send_patch::open_send_patch_panel;
use crate::views::repo_detail::new_pull_request::open_new_pull_panel;
/// What kind of ref the header selectors switch to.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -64,13 +70,17 @@ enum RefKind {
}
/// Header actions dispatched by the dropdown menus of the header buttons.
/// `pub(super)`: the pull-request list panel offers the same New-PR / Send-
/// patch actions in its own dropdown.
#[derive(Clone, Action, PartialEq, Eq)]
#[action(namespace = repo_detail, no_json)]
enum RepoAction {
pub(super) enum RepoAction {
/// Open the "new issue" dialog.
NewIssue,
/// Open the "new pull request" dialog.
NewPR,
/// Open the "send patch" panel.
SendPatch,
/// Open the about dialog.
About,
/// Re-push the repository to its grasp servers.
@@ -188,6 +198,9 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>,
/// Upstream repository (from this fork's `u` tag) the user asked to
/// open, while its announcement is still being fetched.
pending_upstream: Option<RepoAddr>,
}
impl RepoDetailView {
@@ -315,6 +328,7 @@ impl RepoDetailView {
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
pending_upstream: None,
}
}
@@ -959,6 +973,77 @@ impl RepoDetailView {
});
}
/// Open the upstream repository (the `u` tag of this fork's announcement).
/// When the upstream announcement is not in the local database yet,
/// subscribe for it and open the panel as soon as it lands.
fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_upstream.is_some() {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else {
return;
};
if let Some(found) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned()
{
open_repo_panel(&self.dock_area, &found, window, &mut *cx);
return;
}
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
});
self.pending_upstream = Some(addr);
let task = cx.spawn_in(window, async move |this, cx| {
for _ in 0..60 {
cx.background_executor()
.timer(Duration::from_millis(250))
.await;
let opened = this.update_in(cx, |this, window, cx| {
let Some(addr) = this.pending_upstream.clone() else {
return true;
};
let found = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned();
match found {
Some(found) => {
this.pending_upstream = None;
open_repo_panel(&this.dock_area, &found, window, &mut *cx);
true
}
None => false,
}
})?;
if opened {
return Ok(());
}
}
this.update(cx, |this, _cx| this.pending_upstream = None)?;
Ok(())
});
self.tasks.push(task);
}
/// Check out `name` (a branch or tag picked in the header) and refresh
/// the explorer once the switch completes.
fn switch_ref(
@@ -1285,7 +1370,12 @@ impl RepoDetailView {
}
RepoAction::NewPR => {
if let Some(store) = this.store.clone() {
open_new_pull_request_dialog(store, window, cx);
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
}
RepoAction::SendPatch => {
if let Some(store) = this.store.clone() {
open_send_patch_panel(this.dock_area.clone(), store, window, cx);
}
}
RepoAction::About => {
@@ -1332,6 +1422,7 @@ impl RepoDetailView {
.text_ellipsis()
.child(description),
)
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
.child(
h_flex()
.mt_2()
@@ -1432,8 +1523,18 @@ impl RepoDetailView {
.gap_2()
.text_sm()
.child(Icon::new(IconName::Plus))
.child("New PR")
.child("New Pull Request")
})
.menu_element(
Box::new(RepoAction::SendPatch),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::File))
.child("Send Patch")
},
)
}),
)
.child(
@@ -2015,3 +2116,76 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
SharedString::from(url)
}
/// The "Forked from …" row of the detail header: a clickable link to the
/// upstream repository when the `u` tag references a NIP-34 repo,
/// plain text when it only carries a git URL.
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
let upstream = announcement.upstream.as_ref()?;
let (label, clickable) = match &upstream.addr {
Some(addr) => {
// Prefer the upstream's display name when its announcement
// is already known locally fall back to its repository id.
let name = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == *addr)
.map(|a| {
a.name
.clone()
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
(SharedString::from(format!("Forked from {name}")), true)
}
None => (upstream.display(), false),
};
let row = h_flex()
.gap_1()
.items_center()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::GitBranch).small())
.child(div().whitespace_nowrap().text_ellipsis().child(label));
Some(if clickable {
row.id("fork-upstream")
.cursor_pointer()
.hover(|this| this.text_color(cx.theme().foreground))
.on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx)))
.into_any_element()
} else {
row.into_any_element()
})
}
/// Open `announcement` as a repository panel in the dock's center, returning
/// the new detail view. Shared by the explore list, the sidebar and fork
/// links so every entry point opens repositories identically.
pub(crate) fn open_repo_panel(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
window: &mut Window,
cx: &mut App,
) -> Entity<RepoDetailView> {
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail.clone()),
DockPlacement::Center,
None,
window,
cx,
);
});
}
detail
}
@@ -0,0 +1,867 @@
//! The "new pull request" panel: pick a local checkout, a base and a
//! compare branch (GitHub-style), review the diff and the commit list, then
//! publish the PR with only a title and an optional description. The patch
//! series is generated from the checkout at submit time; there is no patch
//! input.
use std::path::{Path, PathBuf};
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
size,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
};
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_component::scroll::Scrollbar;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::spinner::Spinner;
use gpui_component::{
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list,
};
use signed_git::{
format_patch_between, merge_base, worktree_commit_range_commits, worktree_commit_range_diff,
};
use signed_state::RepoStore;
use signed_ui::placeholder;
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
use super::diff::{CommitDiffView, DiffPane};
/// The "new pull request" panel of a repository.
///
/// Both branch selectors list the branches of a user-chosen local checkout;
/// the compare view (Files/Commits tabs) is built from `merge-base..compare`
/// in that checkout, and the patch series published with the PR is generated
/// from the same range at submit time.
pub struct NewPullRequestView {
focus_handle: FocusHandle,
/// Dock area the panel lives in; commit diffs are opened there.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository (for the announced HEAD default).
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// The user's checkout: where both branches live and where the tip is
/// pushed from.
repo_path: Option<PathBuf>,
/// Branches of the checkout, backing both selectors.
branches: Vec<SharedString>,
/// Selected base branch (the target of the PR).
base: SharedString,
/// Selected compare branch (the source of the PR).
compare: SharedString,
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Title input (required).
subject: Entity<InputState>,
/// Description input (optional).
description: Entity<TextareaState>,
/// Merge base of the selected branches; `None` until the compare loads.
merge_base: Option<String>,
/// Commits in `merge_base..compare`, newest first.
commits: Option<Vec<signed_git::FileCommit>>,
/// The compare is being computed.
loading: bool,
/// Error of the last compare or submit attempt.
error: Option<SharedString>,
/// A submit (patch generation + publish) is in flight.
submitting: bool,
/// Bumped on every branch switch; stale compare results are discarded.
compare_generation: u64,
/// Active tab: 0 = Files, 1 = Commits.
active_tab: usize,
/// The compare diff (Files tab).
pane: Entity<DiffPane>,
/// Virtual list state of the Commits tab.
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
_subscriptions: Vec<Subscription>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
}
impl NewPullRequestView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new);
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe..."));
let base_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let compare_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let subscriptions = vec![
// Re-evaluate the Create button's enabled state as the title
// changes.
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.base = name.clone();
this.reload_compare(window, cx);
}
}),
cx.subscribe_in(
&compare_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.compare = name.clone();
this.reload_compare(window, cx);
}
},
),
];
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
repo_path: None,
branches: Vec::new(),
base: SharedString::default(),
compare: SharedString::default(),
base_select,
compare_select,
subject,
description,
merge_base: None,
commits: None,
loading: false,
error: None,
submitting: false,
compare_generation: 0,
active_tab: 0,
pane,
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
_subscriptions: subscriptions,
tasks: Vec::new(),
}
}
/// Prompt for a local checkout; on success populate the branch selectors
/// (defaults: the announced HEAD branch for the base, the checkout's
/// current branch for the compare) and load the compare.
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Choose local checkout".into()),
});
let task = cx.spawn_in(window, async move |this, cx| {
// `Ok(Ok(Some(paths)))` means the user picked a folder; a
// cancel (or a picker failure) resolves to anything else.
let picked = match prompt.await {
Ok(Ok(Some(mut paths))) => paths.pop(),
_ => None,
};
let Some(path) = picked else {
return Ok(());
};
let path = path.to_string_lossy().to_string();
// Branches and the current branch are read off the UI thread.
let info = cx
.background_spawn({
let path = path.clone();
async move {
let repo = gix::open(Path::new(&path)).ok()?;
let branches =
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
let current = signed_git::current_branch(&repo).ok().flatten();
Some((branches, current))
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.apply_checkout(path, info, window, cx);
})?;
Ok(())
});
self.tasks.push(task);
}
/// Apply a picked checkout: fill the selectors and load the compare.
fn apply_checkout(
&mut self,
path: String,
info: Option<(Vec<String>, Option<String>)>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((branches, current)) = info else {
self.error = Some("The chosen folder is not a git repository".into());
self.repo_path = None;
self.branches.clear();
self.merge_base = None;
self.commits = None;
self.pane.update(cx, |pane, cx| pane.clear(cx));
cx.notify();
return;
};
if branches.is_empty() {
self.error = Some("The repository has no branches yet".into());
self.repo_path = None;
self.branches.clear();
cx.notify();
return;
}
// Defaults: the announced HEAD branch when the checkout has it
// (falling back to `main`, then the first branch); the checkout's
// current branch for the compare side.
let announced = self.store.read(cx).head.clone();
let base = announced
.as_ref()
.filter(|branch| branches.contains(branch))
.cloned()
.or_else(|| branches.iter().find(|branch| *branch == "main").cloned())
.unwrap_or_else(|| branches[0].clone());
let compare = current
.filter(|branch| branches.contains(branch))
.unwrap_or_else(|| base.clone());
self.repo_path = Some(PathBuf::from(&path));
self.error = None;
self.branches = branches.into_iter().map(SharedString::from).collect();
let branches = self.branches.clone();
let base = SharedString::from(base.clone());
let compare = SharedString::from(compare.clone());
self.base = base.clone();
self.compare = compare.clone();
self.base_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches.clone()), window, cx);
state.set_selected_values(&[base], window, cx);
});
self.compare_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
state.set_selected_values(&[compare], window, cx);
});
self.reload_compare(window, cx);
}
/// (Re)compute `merge_base..compare` of the selected branches on a
/// background task: the merge base, the commit list and the diff.
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let base = self.base.to_string();
let compare = self.compare.to_string();
self.loading = true;
self.error = None;
self.compare_generation += 1;
let generation = self.compare_generation;
cx.notify();
if base == compare {
self.loading = false;
self.merge_base = None;
self.commits = None;
self.pane.update(cx, |pane, cx| pane.clear(cx));
self.error = Some("Choose different base and compare branches".into());
cx.notify();
return;
}
let task = cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn({
let repo_path = repo_path.clone();
let base = base.clone();
let compare = compare.clone();
async move {
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
.ok_or_else(|| {
anyhow::anyhow!("{base} and {compare} share no common ancestor")
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
})
.await;
this.update_in(cx, |this, _window, cx| {
// A stale result (the branches changed mid-flight) must not
// clobber a newer compare; the newer task clears the flag.
if generation != this.compare_generation {
return;
}
this.loading = false;
match result {
Ok((merge_base, commits, diff)) => {
this.merge_base = Some(merge_base);
let count = commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.commits = Some(commits);
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.merge_base = None;
this.commits = None;
this.pane.update(cx, |pane, cx| pane.clear(cx));
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Publish the pull request: generate the patch series from the checkout
/// on a background task, hand it to the store, and close the panel once
/// the publish is underway (errors surface in the pull request list).
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting || self.loading {
return;
}
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let Some(merge_base) = self.merge_base.clone() else {
return;
};
let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string();
let branch_name = self.compare.to_string();
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
self.submitting = true;
self.error = None;
cx.notify();
let task = cx.spawn_in(window, async move |this, cx| {
// Regenerate the series at submit time so the published patch
// covers the current tip of the compare branch.
let patch = cx
.background_spawn({
let repo_path = repo_path.clone();
let merge_base = merge_base.clone();
let branch_name = branch_name.clone();
async move {
format_patch_between(Path::new(&repo_path), &merge_base, &branch_name)
}
})
.await;
let patch = match patch {
Ok(patch) if !patch.is_empty() => patch,
Ok(_) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some("No commits between the branches to propose".into());
cx.notify();
})?;
return Ok(());
}
Err(error) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some(format!("Failed to generate the patch: {error}").into());
cx.notify();
})?;
return Ok(());
}
};
this.update_in(cx, |this, window, cx| {
this.submitting = false;
store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
description,
Some(branch_name),
patch,
false,
Some(merge_base),
Some(repo_path),
cx,
);
});
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
let entity = entity.clone();
move |_, window, cx| {
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock, cx| {
dock.remove_panel(entity, window, cx);
});
}
}
});
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Open the diff of `commit_id` (from the Commits tab) in a new panel.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| {
CommitDiffView::new(
repo_path,
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::Center, None, window, cx);
});
}
/// The compare bar: base/compare selectors, the checkout chooser and the
/// Create button.
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let has_checkout = self.repo_path.is_some();
let checkout = self.repo_path.clone();
let can_submit = has_checkout
&& !self.loading
&& !self.submitting
&& self.merge_base.is_some()
&& self
.commits
.as_ref()
.is_some_and(|commits| !commits.is_empty())
&& !self.subject.read(cx).value().is_empty();
h_flex()
.px_4()
.h_16()
.w_full()
.gap_2()
.items_end()
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Merge Into"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.base_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_checkout)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Pull From"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.compare_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_checkout)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
Button::new("choose-checkout")
.icon(IconName::Folder)
.ghost()
.tooltip(checkout.as_ref().map_or_else(
|| "Choose a local checkout".into(),
|path| path.display().to_string(),
))
.on_click(cx.listener(|this, _event, window, cx| {
this.choose_checkout(window, cx);
})),
)
.child(div().flex_1())
.child(
Button::new("create-pr")
.primary()
.icon(IconName::Plus)
.loading(self.submitting)
.disabled(!can_submit)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
)
.into_any_element()
}
/// The title and description inputs.
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
.w_full()
.gap_2()
.child(Input::new(&self.subject))
.child(Textarea::new(&self.description).h_24())
.into_any_element()
}
/// The Files/Commits tab bar, mirroring the repository panel's.
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len());
let commits = self.commits.as_ref().map_or(0, |commits| commits.len());
h_flex()
.px_4()
.pb_4()
.w_full()
.gap_2()
.items_center()
.border_b_1()
.border_color(cx.theme().border)
.child(
BaseButton::new("files-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitFile).small())
.child("Files"),
)
.child(count_badge(files, cx))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 0)
.when(self.active_tab == 0, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 0;
cx.notify();
})),
)
.child(
BaseButton::new("commits-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitCommit).small())
.child("Commits"),
)
.child(count_badge(commits, cx))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 1)
.when(self.active_tab == 1, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 1;
cx.notify();
})),
)
.into_any_element()
}
/// The active tab's body.
fn render_content(&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 self.repo_path.is_none() {
return placeholder("Choose a local checkout to compare branches", cx);
}
if self.commits.is_none() && self.error.is_some() {
return placeholder("Nothing to compare", cx);
}
match self.active_tab {
0 => self.pane.clone().into_any_element(),
_ => self.render_commits_tab(cx),
}
}
/// The Commits tab: `merge_base..compare` in a virtual list; clicking a
/// row opens the commit's diff in a new panel.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(commits) = self.commits.as_ref() else {
return placeholder("No commits", cx);
};
if commits.is_empty() {
return placeholder("No commits between the branches", cx);
}
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
v_virtual_list(
view,
"pr-commits",
sizes,
move |this, range, _window, cx| {
let commits = this.commits.as_deref().unwrap_or(&[]);
let view = cx.entity().downgrade();
range
.map(|ix| {
let id = commits[ix].id.clone();
let view = view.clone();
commit_row(
ix,
&commits[ix],
move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx)
});
}
},
cx,
)
})
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
.into_any_element()
}
}
/// The count badge of a tab, styled like the repository panel's.
fn count_badge(count: usize, cx: &App) -> impl IntoElement {
h_flex()
.justify_center()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(count.to_string()))
}
/// The trigger of a branch selector: icon + current selection (or
/// placeholder) + caret. `Combobox` replaces its default trigger entirely.
fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
cx: &App,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_center()
.child(Icon::new(icon).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
.child(
ctx.selection()
.first()
.map(|(_, item)| item.clone())
.or_else(|| ctx.placeholder().cloned())
.unwrap_or_default(),
),
)
.child(Caret::new(ctx.size()).text_color(muted))
.into_any_element()
}
/// Open the "new pull request" panel in the center dock.
pub(super) fn open_new_pull_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let panel = cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
impl BasePanel for NewPullRequestView {
fn panel_name(&self) -> &'static str {
"new-pull-request"
}
}
impl Panel for NewPullRequestView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/new-pull-request",
self.repo_name
)))
}
}
impl EventEmitter<PanelEvent> for NewPullRequestView {}
impl Focusable for NewPullRequestView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewPullRequestView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("new-pr")
.size_full()
.child(
v_flex()
.gap_4()
.child(self.render_compare_bar(cx))
.child(self.render_inputs(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
h_flex()
.px_4()
.py_1()
.w_full()
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(self.render_tabs(cx)),
)
.child(
v_flex()
.flex_1()
.min_h_0()
.w_full()
.child(self.render_content(cx)),
)
}
}
@@ -12,6 +12,8 @@ use gpui::{
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::list::ListItem;
use gpui_component::scroll::{ScrollableElement, Scrollbar};
@@ -20,12 +22,13 @@ use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
};
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 signed_state::{Backend, GitStore, ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
use utils::{relative_time, relative_time_secs};
@@ -183,7 +186,7 @@ impl PullRequestDetailView {
cx.notify();
return;
};
let update = latest_update(store.pull_requests.iter(), &root.id);
let update = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
@@ -1002,7 +1005,7 @@ impl PullRequestDetailView {
/// 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 (title, status, branch, author) = {
let store = self.store.read(cx);
let Some(root) = store
.pull_requests
@@ -1015,9 +1018,14 @@ impl PullRequestDetailView {
activity_subject(root),
store.status_of(root),
branch_name_of(root),
root.pubkey,
)
};
// Only the PR author may publish revisions (kind 1619, NIP-34).
let backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author);
v_flex()
.px_4()
.mb_4()
@@ -1048,6 +1056,38 @@ impl PullRequestDetailView {
.label(branch),
)
})
.when(can_update, |this| {
this.child(
Button::new("update-pr")
.ghost()
.small()
.icon(CustomIconName::GitPullRequest)
.label("Update")
.tooltip("Publish a new revision of this pull request")
.on_click(cx.listener({
let store = self.store.clone();
let pr_id = self.pr_id;
move |_this, _event, window, cx| {
let root = store
.read(cx)
.pull_requests
.iter()
.find(|pr| {
pr.id == pr_id && pr.kind == Kind::GitPullRequest
})
.cloned();
if let Some(root) = root {
open_update_pull_request_dialog(
store.clone(),
root,
window,
cx,
);
}
}
})),
)
})
.when_some(current_commit, |this, id| {
this.child(
h_flex()
@@ -1064,6 +1104,68 @@ impl PullRequestDetailView {
}
}
/// Open the "update pull request" dialog: a patch input that submits a new
/// revision through [`RepoStore::update_pull_request`] when confirmed.
fn open_update_pull_request_dialog(
store: Entity<RepoStore>,
root: Event,
window: &mut Window,
cx: &mut App,
) {
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
});
// Both the dialog body and the submit button capture the root event;
// share it instead of cloning into each closure.
let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| {
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("Update pull request"))
.child(DialogDescription::new().child(
"Publish a new revision with the output of `git format-patch`.",
)),
)
.child(
v_form().child(
field()
.label("Patch")
.child(Textarea::new(&patch).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Update pull request")
.tooltip("Update pull request")
.on_click({
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
move |_event, window, cx| {
let patch = patch.read(cx).value().to_string();
store.update(cx, |store, cx| {
store.update_pull_request(&root, patch, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
/// One sidebar section title.
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
@@ -1121,11 +1223,13 @@ fn branch_name_of(event: &Event) -> Option<String> {
}
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22
/// `E` tag pointing at the root PR event.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &EventId) -> Option<&'a Event> {
let root_hex = root.to_hex();
/// `E` tag pointing at the root PR event. Only updates by the PR author
/// count: the tip of a PR is only mutable by its author (NIP-34).
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| e.pubkey == root.pubkey)
.filter(|e| {
e.tags
.iter()
@@ -1262,16 +1366,35 @@ mod tests {
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root.id).expect("an update");
let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
// The tip of a PR is only mutable by its author: a newer update
// from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
#[test]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root.id).is_none());
assert!(latest_update([&root].into_iter(), &root).is_none());
}
#[test]
@@ -7,22 +7,23 @@ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_base::Button as BaseButton;
use gpui_component::alert::Alert;
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
use utils::relative_time;
use super::RepoAction;
use super::new_pull_request::open_new_pull_panel;
use super::pull_request_detail::PullRequestDetailView;
use super::send_patch::open_send_patch_panel;
/// Height of one pull request row in the virtual list; same layout as an
/// issue row.
@@ -40,8 +41,7 @@ enum PullRequestFilter {
Closed,
/// Pull requests whose resolved status is [`RepoStatus::Draft`].
Draft,
/// Pull requests whose resolved status is [`RepoStatus::Applied`]
/// (i.e. merged).
/// Pull requests whose resolved status is [`RepoStatus::Applied`].
Merged,
}
@@ -217,7 +217,7 @@ impl PullRequestsView {
.child(
h_flex()
.h_12()
.gap_2()
.gap_1()
.child(
SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitPullRequest))
@@ -271,99 +271,47 @@ impl PullRequestsView {
)
.child(div().flex_1())
.child(
SegmentButton::new("new-pr", "New pull request")
.icon(Icon::new(CustomIconName::CirclePlus))
.primary()
.on_click(cx.listener(|this, _event, window, cx| {
open_new_pull_request_dialog(this.store.clone(), window, cx);
})),
h_flex().items_center().child(
DropdownButton::new("new-pr-actions")
.action(
BaseButton::new("new-pr")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().primary)
.hover(|this| this.bg(cx.theme().primary_hover))
.text_sm()
.text_color(cx.theme().primary_foreground)
.child(Icon::new(IconName::Plus))
.child("New"),
)
.on_click(cx.listener(|this, _event, window, cx| {
open_new_pull_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
})),
)
.dropdown_menu(|menu, _, _| {
menu.menu_element(Box::new(RepoAction::SendPatch), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::File))
.child("Send Patch")
})
}),
),
)
.into_any_element()
}
}
/// Open the "new pull request" dialog: a title, an optional description and
/// a patch input that submit through [`RepoStore::open_pull_request`] when
/// confirmed.
pub(super) fn open_new_pull_request_dialog(
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
let description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
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 description = description.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("Description")
.child(Textarea::new(&description).h(px(96.))),
)
.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 description = description.clone();
let patch = patch.clone();
let store = store.clone();
move |_event, window, cx| {
let subject = subject.read(cx).value().to_string();
let description = description.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, description, patch, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
impl BasePanel for PullRequestsView {
fn panel_name(&self) -> &'static str {
"pull-requests"
@@ -437,10 +385,38 @@ impl Render for PullRequestsView {
let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action (e.g. creating
// or updating a PR), shown as dismissible banners above the list.
let (last_error, last_warning) = {
let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone())
};
v_flex()
.size_full()
.image_cache(image_cache("pull-requests", MAX_IMAGES))
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
if action == &RepoAction::SendPatch {
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
}
}))
.child(self.render_header(cx))
.when_some(last_warning, |this, warning| {
this.child(Alert::warning("pr-warning", warning).banner().on_close({
let store = self.store.clone();
move |_event, _window, cx| {
store.update(cx, |store, _| store.last_warning = None);
}
}))
})
.when_some(last_error, |this, error| {
this.child(Alert::error("pr-error", error).banner().on_close({
let store = self.store.clone();
move |_event, _window, cx| {
store.update(cx, |store, _| store.last_error = None);
}
}))
})
.child(
v_flex()
.relative()
@@ -0,0 +1,265 @@
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Subscription, WeakEntity, Window, div, px,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use signed_state::RepoStore;
pub struct SendPatchView {
focus_handle: FocusHandle,
/// Dock area the panel lives in.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Title input (required).
subject: Entity<InputState>,
/// Description input (optional).
description: Entity<TextareaState>,
/// The pasted `git format-patch` output (required).
patch: Entity<TextareaState>,
/// A submit is in flight.
submitting: bool,
/// Error of the last submit attempt (keeps the panel open).
error: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl SendPatchView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
});
// Re-evaluate the Send button's enabled state as the inputs change.
let subscriptions = vec![
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
];
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
subject,
description,
patch,
submitting: false,
error: None,
_subscriptions: subscriptions,
}
}
/// Publish the pull request from the pasted patch. The store validates
/// synchronously (patch shape, per-part size, sign-in); on failure the
/// panel stays open with the error inline, on success it closes — async
/// publish failures surface in the pull request list's banner.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting {
return;
}
let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string();
let patch = self.patch.read(cx).value().to_string();
if patch.is_empty() {
return;
}
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
self.submitting = true;
self.error = None;
cx.notify();
// Errors the store detects before publishing are returned
// synchronously through `last_error`.
let sync_error = store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
description,
None,
patch,
false,
None,
None,
cx,
);
store.last_error.clone()
});
if let Some(error) = sync_error {
self.submitting = false;
self.error = Some(error.into());
cx.notify();
return;
}
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
let entity = entity.clone();
move |_, window, cx| {
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock, cx| {
dock.remove_panel(entity, window, cx);
});
}
}
});
cx.notify();
}
fn render_footer(&self, cx: &mut Context<Self>) -> AnyElement {
let can_submit = !self.submitting
&& !self.subject.read(cx).value().is_empty()
&& !self.patch.read(cx).value().is_empty();
h_flex()
.px_4()
.h_16()
.w_full()
.gap_2()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.child(div().flex_1())
.child(
BaseButton::new("send-patch")
.h_flex()
.h_8()
.px_2()
.gap_1()
.text_sm()
.items_center()
.justify_center()
.bg(cx.theme().primary)
.text_color(cx.theme().primary_foreground)
.hover(|this| this.bg(cx.theme().primary_hover))
.active(|this| this.bg(cx.theme().primary_active))
.map(|this| {
if self.submitting {
this.child(Spinner::new().small())
} else {
this.child(Icon::new(IconName::ArrowUp)).child("Send patch")
}
})
.disabled(!can_submit)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
)
.into_any_element()
}
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
.py_2()
.w_full()
.gap_2()
.child(Input::new(&self.subject))
.child(Textarea::new(&self.description).h(px(64.)))
.into_any_element()
}
fn render_patch(&self, cx: &mut Context<Self>) -> AnyElement {
const MSG: &str = "You can paste a git diff or a git format-patch patch series here.";
v_flex()
.px_4()
.py_2()
.w_full()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(MSG),
)
.child(Textarea::new(&self.patch).h_56())
.into_any_element()
}
}
pub(super) fn open_send_patch_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
impl BasePanel for SendPatchView {
fn panel_name(&self) -> &'static str {
"send-patch"
}
}
impl Panel for SendPatchView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
}
}
impl EventEmitter<PanelEvent> for SendPatchView {}
impl Focusable for SendPatchView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SendPatchView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("send-patch")
.size_full()
.child(
v_flex()
.overflow_y_scrollbar()
.flex_1()
.w_full()
.child(self.render_inputs(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
h_flex()
.px_4()
.py_1()
.w_full()
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(self.render_patch(cx)),
)
.child(self.render_footer(cx))
}
}
+46 -22
View File
@@ -1,7 +1,7 @@
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
@@ -19,7 +19,7 @@ use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar};
use utils::relative_time;
use super::RepoDetailView;
use super::open_repo_panel;
const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
@@ -173,21 +173,7 @@ impl RepoListView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let dock_area = self.dock_area.clone();
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
}
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
}
fn render_card(
@@ -215,6 +201,26 @@ impl RepoListView {
.map(|label| SharedString::from(format!("Updated {label}")))
.unwrap_or_default();
// Fork badge: the upstream's display name when its announcement is
// known locally, otherwise its repository id from the `u` tag.
let fork_label: Option<SharedString> =
announcement.upstream.as_ref().and_then(|upstream| {
let addr = upstream.addr.as_ref()?;
let name = self
.store
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == *addr)
.map(|a| {
a.name
.clone()
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
Some(SharedString::from(format!("forked from {name}")))
});
v_flex()
.id(ix)
.flex_1()
@@ -228,11 +234,29 @@ impl RepoListView {
.child(
h_flex()
.h_10()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
.gap_1p5()
.items_center()
.child(
div()
.min_w_0()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.when_some(fork_label, |this, label| {
this.child(
h_flex()
.gap_1()
.items_center()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.child(Icon::new(CustomIconName::GitBranch).small())
.child(label),
)
}),
)
.child(
div()
@@ -1,4 +1,4 @@
use dock::{DockArea, DockPlacement, panel_handle};
use dock::DockArea;
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
@@ -11,7 +11,7 @@ use settings::SettingsStore;
use signed_core::Announcement;
use signed_state::Backend;
use super::super::RepoDetailView;
use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
/// Shared state for the Create Repository dialog, so async results can be rendered.
@@ -262,13 +262,5 @@ fn open_repo(
window: &mut Window,
cx: &mut App,
) {
let Some(dock_area) = dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| RepoDetailView::new(dock_area.downgrade(), announcement, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
open_repo_panel(&dock_area, &announcement, window, cx);
}
+4 -15
View File
@@ -19,7 +19,7 @@ use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView};
use super::{RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog;
pub(crate) mod grasp_servers;
@@ -100,7 +100,8 @@ impl SidebarPanel {
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
let author = Backend::global(cx).read(cx).current_user();
let backend = Backend::global(cx);
let author = backend.read(cx).current_user();
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() {
@@ -158,19 +159,7 @@ impl SidebarPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
let detail = cx.new(|cx| {
RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx)
});
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
}
/// Open a local repository's detail view in the dock's center; the