From b9054346f71fd843c874e8bf257399a839b30077 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 2 Sep 2026 15:26:50 +0700 Subject: [PATCH] add send patch panel --- crates/signed_core/src/filters.rs | 2 +- crates/signed_core/src/model.rs | 8 +- crates/signed_state/src/repo.rs | 109 +++---- crates/signed_ui/src/status_badge.rs | 12 +- crates/workspace/src/views/repo_detail/mod.rs | 36 ++- .../src/views/repo_detail/new_pull_request.rs | 10 +- .../src/views/repo_detail/pull_requests.rs | 69 +++-- .../src/views/repo_detail/send_patch.rs | 265 ++++++++++++++++++ docs/TODO.md | 5 + 9 files changed, 422 insertions(+), 94 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/send_patch.rs diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 6291ee5..b8f3253 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -6,11 +6,11 @@ use crate::RepoAddr; /// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ + Kind::Comment, Kind::GitPatch, Kind::GitPullRequest, Kind::GitPullRequestUpdate, Kind::GitIssue, - Kind::Comment, Kind::GitStatusOpen, Kind::GitStatusApplied, Kind::GitStatusClosed, diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index f2a712f..7a33340 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -35,10 +35,10 @@ pub struct Announcement { pub hashtags: Vec, } -/// The `u` tag of a fork announcement (NIP-34): the repository this one is a -/// subordinate fork of. The first value is the upstream coordinate -/// (`30617::`) or a git URL; the second is an optional relay hint -/// for the upstream. +/// The `u` tag of a fork announcement (NIP-34) +/// the repository this one is a subordinate fork of. The first value is +/// the upstream coordinate (`30617::`) or a git URL. +/// The second is an optional relay hint for the upstream. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Upstream { /// Raw first value of the `u` tag (coordinate or git URL). diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ed66c7c..3f482e3 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use bitcoin_hashes::sha1::Hash as Sha1Hash; -use gpui::{AppContext, AsyncApp, Context, Subscription, Task, WeakEntity}; +use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity}; use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use signed_core::{ @@ -157,12 +157,22 @@ impl RepoStore { store } + /// Returns the repository's address. pub fn addr(&self) -> &RepoAddr { &self.addr } - /// Filters that make up a repository: announcement, state, activity and - /// deletions targeting it. + /// Returns the repository's name, or "Unknown" if not known. + pub fn name(&self) -> SharedString { + self.announcement + .as_ref() + .map_or(SharedString::default(), |a| { + a.name.clone().unwrap_or(SharedString::from("Unknown")) + }) + } + + /// Filters that make up a repository: announcement, state, + /// activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ // Announcement and state share author and identifier, so they @@ -202,8 +212,7 @@ impl RepoStore { }); } - /// Fetch this repository's events from the bootstrap relays (one-shot, - /// auto-closing subscription). + /// Fetch this repository's events from the bootstrap relays fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let addr = self.addr.clone(); @@ -215,10 +224,8 @@ impl RepoStore { /// Re-query the local database and update all fields. /// - /// Debounced: a short delay collapses bursts of requests (e.g. per-event - /// `NostrUpdate`s), and requests that arrive while a query is running are - /// folded into one follow-up query. The query and processing run on a - /// background thread; only the results are applied on the main thread. + /// The query and processing run on a background thread, + /// only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; @@ -242,7 +249,6 @@ impl RepoStore { self.tasks.push(task); } - /// One query + apply cycle (debounced entry point). fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; @@ -297,13 +303,15 @@ impl RepoStore { // NIP-22 comments reference their root via an `E`/`e` tag rather // than the repository's `a` tag, so query them by the root events // of this repository. - let db = client.database(); let mut seen_comments: HashSet = comments.iter().map(|e| e.id).collect(); + let db = client.database(); + let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); + for filter in filters::comments_for(roots) { for event in db.query(filter).await? { if seen_comments.insert(event.id) { @@ -312,16 +320,17 @@ impl RepoStore { } } - // Status events may omit their `a` tag (NIP-34 makes it - // optional), so also query them by the root events they - // reference. - let db = client.database(); + // Status events may omit their `a` tag, + // so also query them by the root events they reference. let mut seen_statuses: HashSet = statuses.iter().map(|e| e.id).collect(); + let db = client.database(); + let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); + for root in roots { for event in db.query(filters::statuses_for([root])).await? { if seen_statuses.insert(event.id) { @@ -330,17 +339,18 @@ impl RepoStore { } } - // Cover notes (1624) and label events (1985) reference their - // target via an `e` tag, so query them per root like comments - // and statuses. - let db = client.database(); + // Cover notes (1624) and label events (1985) reference + // so query them per root like comments and statuses. let mut seen_cover_notes: HashSet = cover_notes.iter().map(|e| e.id).collect(); let mut seen_labels: HashSet = labels.iter().map(|e| e.id).collect(); + let db = client.database(); + let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); + for root in roots { for event in db.query(filters::annotations_for([root])).await? { if deletions.is_deleted(&event) { @@ -368,12 +378,15 @@ impl RepoStore { .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); + let status_by_root = resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers); + let open_issue_count = issues .iter() .filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open) .count(); + let open_pr_count = pull_requests .iter() .filter(|pr| { @@ -426,8 +439,8 @@ impl RepoStore { let again = this.update(cx, |this, cx| { this.announcement = announcement; - // The announcement may list relays for this repository's - // activity; connect to any we haven't fetched from yet. + // The announcement may list relays for this repository's activity, + // connect to any we haven't fetched from yet. let relays = this .announcement .as_ref() @@ -462,11 +475,13 @@ impl RepoStore { .chain(&this.pull_requests) .map(|e| e.id) .collect::>(); + let new_roots: Vec = roots .iter() .filter(|id| !this.root_fetches.contains(id)) .copied() .collect(); + if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); // Batch the per-root filters: one statuses filter and one @@ -476,6 +491,7 @@ impl RepoStore { let mut root_filters = filters::comments_for(new_roots.clone()); root_filters.push(filters::statuses_for(new_roots.iter().copied())); root_filters.push(filters::annotations_for(new_roots)); + let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); backend.update(cx, |backend, cx| { @@ -495,8 +511,7 @@ impl RepoStore { } })?; - // Requests that arrived while the refresh was running are - // coalesced into one follow-up refresh. + // Requests that arrived while the refresh was running are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } @@ -505,21 +520,19 @@ impl RepoStore { })); } - /// Resolve the status of a root event (issue / patch / PR) per NIP-34: - /// a lookup into the map built on the last refresh. + /// Resolve the status of a root event (issue / patch / PR) per NIP-34 pub fn status_of(&self, root: &Event) -> RepoStatus { status_of(&self.status_by_root, root) } - /// Refresh generation, incremented on every applied refresh. Views use - /// it to key their derived-data caches (filtered lists, counts) so - /// renders that change nothing stay O(1). + /// Refresh generation, incremented on every applied refresh. + /// Views use it to key their derived-data caches. pub fn version(&self) -> u64 { self.version } - /// The effective cover note of `root` (kind 1624), if any: the latest - /// note authored by the root author or a maintainer. + /// The effective cover note of `root` (kind 1624), if any: + /// the latest note authored by the root author or a maintainer. pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { let maintainers = self .announcement @@ -557,14 +570,13 @@ impl RepoStore { /// Number of open issues: issues whose resolved status is /// [`RepoStatus::Open`] (issues without status events default to open). - /// Cached on the last refresh. pub fn issue_count(&self) -> usize { self.open_issue_count } /// Number of open pull requests: root PR events (not PR updates, whose /// status is carried by the root) with a resolved status of - /// [`RepoStatus::Open`]. Cached on the last refresh. + /// [`RepoStatus::Open`]. pub fn pull_request_count(&self) -> usize { self.open_pr_count } @@ -596,15 +608,13 @@ impl RepoStore { .filter(move |e| signed_core::references_root(e, root)) } - /// Comment on a root event (issue / PR) per NIP-34 (kind 1111), using - /// the SDK's NIP-22 `CommentBuilder` so other NIP-34 clients (ngit, - /// GitWorkshop) can thread the comment. + /// Comment on a root event (issue / PR) per NIP-34 (kind 1111) pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { self.reply(root, None, content, cx); } - /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded - /// comment; `None` publishes a top-level comment on the root itself. + /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded comment, + /// `None` publishes a top-level comment on the root itself. pub fn reply( &mut self, root: &Event, @@ -664,6 +674,7 @@ impl RepoStore { .into_iter() .map(str::to_owned) .collect(); + if let Some(oversized) = series .iter() .find(|part| part.len() > MAX_PATCH_EVENT_BYTES) @@ -677,8 +688,7 @@ impl RepoStore { return; } - // The tip of the series is its last commit; `git format-patch` - // orders patches oldest first. + // The tip of the series is its last commit; `git format-patch` orders patches oldest first. let Some(current_commit) = series .last() .and_then(|part| patch_current_commit(part)) @@ -692,16 +702,18 @@ impl RepoStore { }; let backend = Backend::global(cx); + let signer = backend.read(cx).signer(); + if backend.read(cx).current_user().is_none() { self.last_error = Some("Sign in to open a pull request".into()); cx.notify(); return; } - let signer = backend.read(cx).signer(); let addr = self.addr.clone(); let owner = self.addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); + let (push_owner, push_repo_id, push_relays) = self .announcement .as_ref() @@ -742,9 +754,8 @@ impl RepoStore { subject, labels: Vec::new(), branch_name, - // NIP-34: PRs carry at least one clone URL where the - // tip commit can be downloaded; the announced mirrors - // are also the servers the tip is pushed to below. + // NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded, + // the announced mirrors are also the servers the tip is pushed to below. clone: this .announcement .as_ref() @@ -757,8 +768,7 @@ impl RepoStore { } .into_event_builder(); - // NIP-34: the `r` EUC tag lets clients subscribe to all - // PRs of this repository; the SDK builder omits it. + // NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository match this.announcement.as_ref().and_then(|a| a.euc.clone()) { Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")), None => builder, @@ -803,6 +813,7 @@ impl RepoStore { } }) .await; + if pushed == 0 { this.update(cx, |this, cx| { this.last_warning = Some(format!( @@ -818,6 +829,7 @@ impl RepoStore { let backend = Backend::global(cx); backend.update(cx, |backend, cx| backend.publish_event(event, cx)) })?; + let pr_event = match publish_task.await { Ok(event) => event, Err(e) => { @@ -828,8 +840,8 @@ impl RepoStore { } }; - // NIP-34: a draft PR carries a kind-1633 status event; publish - // it right after the PR event so viewers never show it open. + // NIP-34: a draft PR carries a kind-1633 status event, + // publish it right after the PR event so viewers never show it open. if draft { this.update(cx, |this, cx| { this.set_status(&pr_event, RepoStatus::Draft, cx); @@ -842,8 +854,7 @@ impl RepoStore { /// Update a pull request: publish revision patch events chained to the /// original root patch (`t root-revision` and a NIP-10 `e` reply on the - /// first, per NIP-34), then a kind-1619 PR update event carrying the - /// new tip. + /// first, per NIP-34), then a kind-1619 PR update event carrying the new tip. /// /// Only the PR author may update it; other authors must open a new PR. pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context) { diff --git a/crates/signed_ui/src/status_badge.rs b/crates/signed_ui/src/status_badge.rs index 5b58537..771b023 100644 --- a/crates/signed_ui/src/status_badge.rs +++ b/crates/signed_ui/src/status_badge.rs @@ -10,11 +10,11 @@ use signed_core::RepoStatus; pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { let (icon, label, tooltip, bg, fg) = match status { RepoStatus::Open => ( - CustomIconName::GitIssueDone, + CustomIconName::GitIssueOpen, "open", "Issue is open", - cx.theme().primary, - cx.theme().primary_foreground, + cx.theme().secondary, + cx.theme().secondary_foreground, ), RepoStatus::Closed => ( CustomIconName::GitIssueClosed, @@ -31,11 +31,11 @@ pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { cx.theme().accent_foreground, ), RepoStatus::Applied => ( - CustomIconName::GitIssueOpen, + CustomIconName::GitIssueDone, "applied", "Issue is completed", - cx.theme().secondary, - cx.theme().secondary_foreground, + cx.theme().primary, + cx.theme().primary_foreground, ), }; diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0954e89..d6279ec 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -44,6 +44,7 @@ mod issues; mod new_pull_request; mod pull_request_detail; mod pull_requests; +mod send_patch; use about::open_about_dialog; use browser::{ @@ -54,8 +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 new_pull_request::open_new_pull_request_panel; 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)] @@ -67,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. @@ -1363,13 +1370,12 @@ impl RepoDetailView { } RepoAction::NewPR => { if let Some(store) = this.store.clone() { - open_new_pull_request_panel( - this.dock_area.clone(), - store, - this.display_name(cx), - 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 => { @@ -1517,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( diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs index f7362c1..6b7ef6c 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -93,10 +93,10 @@ impl NewPullRequestView { pub fn new( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, window: &mut Window, cx: &mut Context, ) -> 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...")); @@ -796,16 +796,14 @@ fn render_ref_trigger( .into_any_element() } -/// Open the "new pull request" panel for `store` in the center dock. -pub(super) fn open_new_pull_request_panel( +/// Open the "new pull request" panel in the center dock. +pub(super) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, window: &mut Window, cx: &mut App, ) { - let panel = - cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, repo_name, window, cx)); + 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); diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 269cbda..e7e42ce 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -7,18 +7,23 @@ use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, WeakEntity, Window, div, px, size, }; +use gpui_base::Button as BaseButton; use gpui_component::alert::Alert; use gpui_component::scroll::Scrollbar; -use gpui_component::{ActiveTheme, Icon, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list}; +use gpui_component::{ + 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::new_pull_request::open_new_pull_request_panel; +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. @@ -36,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, } @@ -213,7 +217,7 @@ impl PullRequestsView { .child( h_flex() .h_12() - .gap_2() + .gap_1() .child( SegmentButton::new("all", "All") .icon(Icon::new(CustomIconName::GitPullRequest)) @@ -267,18 +271,42 @@ 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_panel( - this.dock_area.clone(), - this.store.clone(), - this.repo_name.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() } @@ -367,6 +395,11 @@ impl Render for PullRequestsView { 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({ diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/repo_detail/send_patch.rs new file mode 100644 index 0000000..9159553 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/send_patch.rs @@ -0,0 +1,265 @@ +use assets::CustomIconName; +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_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::{ActiveTheme, Disableable, 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, + /// Store of the target repository. + store: Entity, + /// Display name of the repository, for the panel title. + repo_name: SharedString, + /// Title input (required). + subject: Entity, + /// Description input (optional). + description: Entity, + /// The pasted `git format-patch` output (required). + patch: Entity, + /// A submit is in flight. + submitting: bool, + /// Error of the last submit attempt (keeps the panel open). + error: Option, + _subscriptions: Vec, +} + +impl SendPatchView { + pub fn new( + dock_area: WeakEntity, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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("Paste `git format-patch` output here...") + }); + + // 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) { + 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(); + } + + /// Top bar: a short caption and the Send button. + fn render_header(&self, cx: &mut Context) -> 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_12() + .w_full() + .gap_2() + .items_center() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_2() + .items_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::FileText).small().flex_shrink_0()) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child("Send a patch from `git format-patch` output"), + ), + ) + .child( + Button::new("send-patch") + .icon(CustomIconName::CirclePlus) + .label("Send patch") + .primary() + .loading(self.submitting) + .disabled(!can_submit) + .on_click(cx.listener(|this, _event, window, cx| { + this.submit(window, cx); + })), + ) + .into_any_element() + } + + /// Title and description inputs. + fn render_inputs(&self, cx: &mut Context) -> AnyElement { + v_flex() + .px_4() + .py_2() + .w_full() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child(Input::new(&self.subject)) + .child(Textarea::new(&self.description).h(px(64.))) + .into_any_element() + } + + /// The patch textarea, the main content of the panel. + fn render_patch(&self, cx: &mut Context) -> AnyElement { + v_flex() + .px_4() + .py_2() + .w_full() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Patch — `git format-patch` output"), + ) + .child(Textarea::new(&self.patch).h(px(240.))) + .into_any_element() + } +} + +/// Open the "send patch" panel for `store` in the center dock. +pub(super) fn open_send_patch_panel( + dock_area: WeakEntity, + store: Entity, + 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) -> impl IntoElement { + div().child(SharedString::from(format!("{}/send-patch", self.repo_name))) + } +} + +impl EventEmitter 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) -> impl IntoElement { + v_flex() + .id("send-patch") + .size_full() + .child(self.render_header(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_patch(cx)) + } +} diff --git a/docs/TODO.md b/docs/TODO.md index f822530..55f7568 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -17,6 +17,11 @@ - [x] Patch is generated from the checkout at submit time (`format_patch_between` on the stored merge base); panel closes after publishing, errors surface in the PR list banner. - [x] Removed with the dialog: paste textarea, draft checkbox, branch-name input and the mirror-clone apply-check hint (store behavior unchanged: `open_pull_request` still publishes the series + `branch-name`/`merge-base`/`r` tags and pushes the tip). +### Send patch panel (classic paste flow) + +- [x] "Send patch" entry in the repo header PRs dropdown (`RepoAction::SendPatch`) and a "New pull request ▾ Send patch" dropdown replacing the PR list's plain new-PR button. +- [x] `send_patch.rs` center panel: title + optional description + `git format-patch` paste area; submits through `RepoStore::open_pull_request` (no checkout, no `branch-name`/`merge-base`). Synchronous store errors (malformed/oversized patch, sign-in) keep the panel open with an inline error; the panel closes once the publish is underway. + - [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog (dialog since replaced by the panel above). - [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header. - [x] P1: `latest_update` filters by PR author.