From 15224277bc95f9558bb6222ce9381931d5ebac4f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 2 Sep 2026 11:00:00 +0700 Subject: [PATCH] update pull request ui --- .../src/views/repo_detail/commits.rs | 61 +- .../workspace/src/views/repo_detail/diff.rs | 315 ++++--- crates/workspace/src/views/repo_detail/mod.rs | 12 +- .../src/views/repo_detail/new_pull_request.rs | 859 ++++++++++++++++++ .../src/views/repo_detail/pull_requests.rs | 526 +---------- docs/TODO.md | 13 +- 6 files changed, 1104 insertions(+), 682 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/new_pull_request.rs diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 3c757b6..14ce94c 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -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, + 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(), ) diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 8d0cacc..e724352 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -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, - /// The diff is being computed on a background task. - loading: bool, - error: Option, /// Changed-files explorer state. tree_state: Entity, /// Path of the file whose diff is shown in the detail column. @@ -55,114 +46,60 @@ pub struct CommitDiffView { item_sizes: Rc>>, /// Virtual list state of the diff rows. scroll_handle: VirtualListScrollHandle, - /// In-flight tasks; pruned on every push (see [`helpers::track`]). - tasks: Vec>>, } -impl CommitDiffView { - pub fn new( - worktree: PathBuf, - repo_name: SharedString, - commit_id: String, - window: &mut Window, - cx: &mut Context, - ) -> 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 { - 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.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 = 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) { + let mut paths: Vec = 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.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) -> 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) -> 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, + /// Changed-files explorer and per-file diff, shared with the compare + /// view of the new-pull-request panel. + pane: Entity, + /// In-flight tasks; pruned on every push (see [`helpers::track`]). + tasks: Vec>>, +} + +impl CommitDiffView { + pub fn new( + worktree: PathBuf, + repo_name: SharedString, + commit_id: String, + window: &mut Window, + cx: &mut Context, + ) -> 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.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) -> 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) -> 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)) } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 6550103..0954e89 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -41,6 +41,7 @@ mod helpers; mod init_dialog; mod issue_detail; mod issues; +mod new_pull_request; mod pull_request_detail; mod pull_requests; @@ -53,7 +54,8 @@ 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 new_pull_request::open_new_pull_request_panel; +use pull_requests::PullRequestsView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -1361,7 +1363,13 @@ impl RepoDetailView { } RepoAction::NewPR => { if let Some(store) = this.store.clone() { - open_new_pull_request_dialog(store, window, cx); + open_new_pull_request_panel( + this.dock_area.clone(), + store, + this.display_name(cx), + window, + cx, + ); } } RepoAction::About => { diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs new file mode 100644 index 0000000..81c4d9c --- /dev/null +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -0,0 +1,859 @@ +//! 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; +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, + /// Store of the target repository (for the announced HEAD default). + store: Entity, + /// 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, + /// Branches of the checkout, backing both selectors. + branches: Vec, + /// Selected base branch (the target of the PR). + base: SharedString, + /// Selected compare branch (the source of the PR). + compare: SharedString, + base_select: Entity>>, + compare_select: Entity>>, + /// Title input (required). + subject: Entity, + /// Description input (optional). + description: Entity, + /// Merge base of the selected branches; `None` until the compare loads. + merge_base: Option, + /// Commits in `merge_base..compare`, newest first. + commits: Option>, + /// The compare is being computed. + loading: bool, + /// Error of the last compare or submit attempt. + error: Option, + /// 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, + /// Virtual list state of the Commits tab. + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + _subscriptions: Vec, + tasks: Vec>>, +} + +impl NewPullRequestView { + pub fn new( + dock_area: WeakEntity, + store: Entity, + repo_name: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + 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 (optional)")); + let pane = cx.new(DiffPane::new); + + let base_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let compare_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::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) { + let handle = window.window_handle(); + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose local checkout".into()), + }); + + let task = cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(mut paths))) = prompt.await + && let Some(path) = paths.pop() + { + let path = path.to_string_lossy().to_string(); + // Branches and the current branch are read off the UI thread. + let info = cx + .background_executor() + .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; + + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |this, 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, Option)>, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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) { + 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) { + 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) -> 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_12() + .w_full() + .gap_2() + .items_center() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("base"), + ) + .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( + Icon::new(IconName::ArrowRight) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("compare"), + ) + .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() + .label("Create pull request") + .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) -> AnyElement { + v_flex() + .px_4() + .py_2() + .w_full() + .gap_2() + .child(Input::new(&self.subject)) + .child(Textarea::new(&self.description).h_32()) + .into_any_element() + } + + /// The Files/Commits tab bar, mirroring the repository panel's. + fn render_tabs(&self, cx: &mut Context) -> 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() + .h_9() + .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) -> 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) -> 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>, + 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 for `store` in the center dock. +pub(super) fn open_new_pull_request_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 _ = 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) -> impl IntoElement { + div().child(SharedString::from(format!( + "{}/new-pull-request", + self.repo_name + ))) + } +} + +impl EventEmitter 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) -> impl IntoElement { + v_flex() + .id("new-pr") + .size_full() + .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)), + ) + } +} diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 916edbf..269cbda 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -1,32 +1,23 @@ -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, WeakEntity, Window, div, px, size, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, WeakEntity, Window, div, px, size, }; use gpui_component::alert::Alert; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::checkbox::Checkbox; -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_component::scroll::Scrollbar; -use gpui_component::{ - ActiveTheme, Disableable, Icon, IconName, VirtualListScrollHandle, WindowExt, h_flex, v_flex, - v_virtual_list, -}; +use gpui_component::{ActiveTheme, Icon, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list}; use nostr::prelude::{EventId, Kind}; use signed_core::{RepoStatus, activity_subject}; -use signed_git::{format_patch_between, merge_base, patch_applies}; -use signed_state::{GitStore, ProfileStore, RepoStore}; +use signed_state::{ProfileStore, RepoStore}; use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; +use super::new_pull_request::open_new_pull_request_panel; use super::pull_request_detail::PullRequestDetailView; /// Height of one pull request row in the virtual list; same layout as an @@ -280,512 +271,19 @@ impl PullRequestsView { .icon(Icon::new(CustomIconName::CirclePlus)) .primary() .on_click(cx.listener(|this, _event, window, cx| { - open_new_pull_request_dialog(this.store.clone(), window, cx); + open_new_pull_request_panel( + this.dock_area.clone(), + this.store.clone(), + this.repo_name.clone(), + window, + cx, + ); })), ) .into_any_element() } } -/// A patch series generated from a local repository, with the metadata -/// derived from it. -struct GeneratedPatch { - /// The `git format-patch` series (fills the patch textarea). - patch: String, - /// The merge base with the target branch, as hex. - merge_base: Option, -} - -/// State of the new pull request dialog, so the async generation, the -/// apply check and the draft checkbox re-render. -#[derive(Default)] -struct NewPullRequestDialogState { - draft: bool, - /// The last generated patch series; its merge base is reused at submit - /// only while the patch textarea is unchanged. - generated: Option, - /// Result of the pre-publish applicability check against the app's - /// mirror clone of the target repository. - apply_check: Option>, - /// A patch generation is in flight. - generating: bool, - /// Error of the last generation attempt. - error: Option, -} - -impl NewPullRequestDialogState { - /// Text and whether it is good news, for the line under the patch field. - fn apply_check_message(&self) -> Option<(SharedString, bool)> { - match &self.apply_check { - Some(Ok(())) => Some(( - "Applies cleanly to the repository's default branch".into(), - true, - )), - Some(Err(error)) => Some(( - format!("May not apply cleanly to the repository's default branch: {error}").into(), - false, - )), - None => None, - } - } -} - -/// Open the "new pull request" dialog: a title, an optional description, -/// an optional branch name and a patch input that submit through -/// [`RepoStore::open_pull_request`] when confirmed. The patch can either be -/// pasted, or generated from a local checkout: pick a repository, a source -/// and a target branch, and the app runs `git format-patch` itself and -/// checks the series against the app's mirror clone of the target. -pub(super) fn open_new_pull_request_dialog( - store: Entity, - window: &mut Window, - cx: &mut App, -) { - let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); - let description = - cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change...")); - let branch = cx.new(|cx| InputState::new(window, cx).placeholder("Branch name (optional)")); - let repo_path = cx.new(|cx| InputState::new(window, cx).placeholder("Pick a local checkout…")); - let source = cx.new(|cx| InputState::new(window, cx).placeholder("Source branch")); - let target = cx.new(|cx| InputState::new(window, cx).placeholder("Target branch")); - let patch = cx - .new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output...")); - let state = cx.new(|_| NewPullRequestDialogState::default()); - - window.open_dialog(cx, move |dialog, _window, _cx| { - let subject = subject.clone(); - let description = description.clone(); - let branch = branch.clone(); - let repo_path = repo_path.clone(); - let source = source.clone(); - let target = target.clone(); - let patch = patch.clone(); - let store = store.clone(); - let state = state.clone(); - - dialog - .width(px(560.)) - .margin_top(px(50.)) - .content(move |body, _window, cx| { - let generating = state.read(cx).generating; - let draft = state.read(cx).draft; - let error = state.read(cx).error.clone(); - let apply_check = state.read(cx).apply_check_message(); - 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("Local repository") - .description( - "Generate the patch from a local checkout; leave empty to paste it", - ) - .child( - h_flex() - .gap_1() - .items_center() - .child( - div() - .flex_1() - .child(Input::new(&repo_path).disabled(true)), - ) - .child( - Button::new("choose-checkout") - .icon(IconName::FolderOpen) - .ghost() - .tooltip("Choose local checkout") - .on_click({ - let repo_path = repo_path.clone(); - let source = source.clone(); - let target = target.clone(); - let patch = patch.clone(); - let branch = branch.clone(); - let state = state.clone(); - let store = store.clone(); - move |_ev, window, cx| { - choose_local_repo( - &repo_path, - &source, - &target, - &patch, - &branch, - &state, - &store, - window, - cx, - ); - } - }), - ) - .child( - Button::new("generate-patch") - .ghost() - .label("Generate") - .tooltip( - "Generate the patch from the local checkout", - ) - .loading(generating) - .disabled(generating) - .on_click({ - let repo_path = repo_path.clone(); - let source = source.clone(); - let target = target.clone(); - let patch = patch.clone(); - let branch = branch.clone(); - let state = state.clone(); - let store = store.clone(); - move |_ev, window, cx| { - let path = - repo_path.read(cx).value().to_string(); - let source = - source.read(cx).value().to_string(); - let target = - target.read(cx).value().to_string(); - if !path.is_empty() - && !source.is_empty() - && !target.is_empty() - { - generate_patch( - &state, - &patch, - &branch, - path, - source, - target, - &store, - window, - cx, - ); - } - } - }), - ), - ), - ) - .child( - field() - .label("Source branch") - .child(Input::new(&source)), - ) - .child( - field() - .label("Target branch") - .child(Input::new(&target)), - ) - .child( - field() - .label("Branch") - .description("Optional: the branch the change is proposed from") - .child(Input::new(&branch)), - ) - .child( - field().label("Patch").child( - v_flex() - .gap_1() - .child(Textarea::new(&patch).h(px(140.))) - .when_some(apply_check, |this, (message, ok)| { - this.child( - div() - .text_xs() - .text_color(if ok { - cx.theme().success - } else { - cx.theme().warning - }) - .child(message), - ) - }) - .when_some(error, |this, message| { - this.child( - div() - .text_xs() - .text_color(cx.theme().danger) - .child(message), - ) - }), - ), - ) - .child( - field().child( - Checkbox::new("pr-draft") - .label("Create as draft") - .checked(draft) - .on_click({ - let state = state.clone(); - move |checked, _window, cx| { - state.update(cx, |state, _| state.draft = *checked); - } - }), - ), - ), - ) - .child( - DialogFooter::new().justify_end().child( - Button::new("submit") - .primary() - .label("Create pull request") - .tooltip("Create pull request") - .loading(generating) - .disabled(generating) - .on_click({ - let subject = subject.clone(); - let description = description.clone(); - let branch = branch.clone(); - let patch = patch.clone(); - let repo_path = repo_path.clone(); - let store = store.clone(); - let state = state.clone(); - - move |_event, window, cx| { - if state.read(cx).generating { - return; - } - let subject = subject.read(cx).value().to_string(); - let description = description.read(cx).value().to_string(); - let branch = branch.read(cx).value().to_string(); - let patch = patch.read(cx).value().to_string(); - let subject = (!subject.is_empty()).then_some(subject); - let branch = (!branch.is_empty()).then_some(branch); - let draft = state.read(cx).draft; - // The generated merge base stays valid - // only while the patch is unchanged; an - // edited patch falls back to none. - let merge_base = state - .read(cx) - .generated - .as_ref() - .filter(|generated| generated.patch == patch) - .and_then(|generated| generated.merge_base.clone()); - // The checkout (when set) is where the - // tip commit is pushed from, so other - // clients can fetch it. - let repo_path = repo_path.read(cx).value().to_string(); - let push_from = (!repo_path.is_empty()) - .then(|| PathBuf::from(repo_path)); - - store.update(cx, |store, cx| { - store.open_pull_request( - subject, - description, - branch, - patch, - draft, - merge_base, - push_from, - cx, - ); - }); - - window.close_dialog(cx); - } - }), - ), - ) - }) - }); -} - -/// Prompt for a local checkout, fill the source/target defaults (the -/// checkout's current branch and the repository's announced HEAD) and -/// generate the patch series right away. -#[allow(clippy::too_many_arguments)] -fn choose_local_repo( - repo_path: &Entity, - source: &Entity, - target: &Entity, - patch: &Entity, - branch: &Entity, - state: &Entity, - store: &Entity, - window: &mut Window, - cx: &mut App, -) { - let handle = window.window_handle(); - let repo_path = repo_path.clone(); - let source = source.clone(); - let target = target.clone(); - let patch = patch.clone(); - let branch = branch.clone(); - let state = state.clone(); - let store = store.clone(); - // The announced HEAD branch is the natural target default. - let target_default = store.read(cx).head.clone().unwrap_or_default(); - - let prompt = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Choose local checkout".into()), - }); - - cx.spawn(async move |cx| { - if let Ok(Ok(Some(mut paths))) = prompt.await - && let Some(path) = paths.pop() - { - let path = path.to_string_lossy().to_string(); - - // The checkout's current branch is the source default; resolve - // it off the UI thread. - let current = cx - .background_executor() - .spawn({ - let path = path.clone(); - async move { - gix::open(Path::new(&path)) - .ok() - .and_then(|repo| signed_git::current_branch(&repo).ok().flatten()) - } - }) - .await; - - let _ = handle.update(cx, |_, window, cx| { - repo_path.update(cx, |input, cx| { - input.set_value(path.clone(), window, cx); - }); - source.update(cx, |input, cx| { - input.set_value(current.clone().unwrap_or_default(), window, cx); - }); - target.update(cx, |input, cx| { - input.set_value(target_default.clone(), window, cx); - }); - - if let Some(current) = current - && !current.is_empty() - && !target_default.is_empty() - { - generate_patch( - &state, - &patch, - &branch, - path, - current, - target_default, - &store, - window, - cx, - ); - } - }); - } - }) - .detach(); -} - -/// Generate the patch series `source..target` of the local checkout at -/// `repo_path`, fill the patch textarea and record the merge base and the -/// pre-publish applicability check in `state`. -#[allow(clippy::too_many_arguments)] -fn generate_patch( - state: &Entity, - patch_input: &Entity, - branch_input: &Entity, - repo_path: String, - source: String, - target: String, - store: &Entity, - window: &mut Window, - cx: &mut App, -) { - state.update(cx, |state, cx| { - state.generating = true; - state.error = None; - state.apply_check = None; - cx.notify(); - }); - - let cache = GitStore::global(cx).cache().clone(); - let (addr, clone_urls) = { - let store = store.read(cx); - ( - store.addr().clone(), - store - .announcement - .as_ref() - .map(|a| { - a.clone - .iter() - .map(ToString::to_string) - .collect::>() - }) - .unwrap_or_default(), - ) - }; - - let handle = window.window_handle(); - let state = state.clone(); - let patch_input = patch_input.clone(); - let branch_input = branch_input.clone(); - - let task = cx.spawn(async move |cx| { - // The branch-name tag defaults to the source branch; keep a copy - // for the UI update after the background generation moves it. - let source_label = source.clone(); - let generated = cx - .background_executor() - .spawn(async move { - let base = - merge_base(Path::new(&repo_path), &source, &target)?.ok_or_else(|| { - anyhow::anyhow!("{source} and {target} share no common ancestor") - })?; - let patch = format_patch_between(Path::new(&repo_path), &base, &source)?; - // Best-effort: does the series apply to the current default - // branch of the app's mirror clone of the target repository? - let check = cache - .ensure_clone(&addr, &clone_urls) - .ok() - .and_then(|repo| repo.workdir().map(|workdir| workdir.to_path_buf())) - .map(|workdir| patch_applies(&workdir, &patch).map_err(|e| e.to_string())); - Ok::<_, anyhow::Error>((patch, Some(base), check)) - }) - .await; - - let _ = handle.update(cx, |_, window, cx| match generated { - Ok((patch, merge_base, check)) => { - patch_input.update(cx, |input, cx| { - input.set_value(patch.clone(), window, cx); - }); - // The branch-name tag defaults to the source branch. - if branch_input.read(cx).value().is_empty() { - branch_input.update(cx, |input, cx| { - input.set_value(source_label.clone(), window, cx); - }); - } - state.update(cx, |state, cx| { - state.generating = false; - state.generated = Some(GeneratedPatch { patch, merge_base }); - state.apply_check = check; - cx.notify(); - }); - } - Err(error) => state.update(cx, |state, cx| { - state.generating = false; - state.error = Some(error.to_string().into()); - cx.notify(); - }), - }); - }); - task.detach(); -} - impl BasePanel for PullRequestsView { fn panel_name(&self) -> &'static str { "pull-requests" diff --git a/docs/TODO.md b/docs/TODO.md index 47df0cd..f822530 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,10 +8,19 @@ ## Pull request improvement -- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog. +### New pull request panel (replaces the dialog) + +- [x] "New pull request" (PR list header + repo header `New PR`) opens a center panel instead of the paste dialog: + - [x] Base/compare branch selectors fed from a user-chosen local checkout (GitHub-style; defaults: announced HEAD for base, checkout's current branch for compare). + - [x] Files/Commits tabs like the repo panel: diff of `merge-base..compare` (shared `DiffPane` widget, also extracted for the commit diff panel) + virtual commit list with count badge; clicking a commit opens its diff panel. + - [x] Only two inputs: title (required, gates the Create button) and description (optional). + - [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). + +- [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. -- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field. +- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field (superseded by the panel's live compare view). - [x] P3: push tip to grasp servers under `refs/nostr/` before publishing (from the local checkout); multi-commit series published as NIP-10-chained 1617 events with a 60 KB per-patch cap; PR list shows dismissible error/warning banners (incl. push failures). - [x] P4: merge status tags — `merge_pull_request` publishes 1631 with `applied-as-commits` + `r` per applied commit and `q`/`e`-reply tags per applied patch event.