This commit is contained in:
2026-09-13 15:54:17 +07:00
parent b59f6a95de
commit 93d96ca9ee
6 changed files with 242 additions and 188 deletions
+57 -16
View File
@@ -1,3 +1,6 @@
use std::collections::HashSet;
use std::path::PathBuf;
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, Context, SharedString, div, transparent_white};
@@ -10,6 +13,48 @@ use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout
use super::RepoDetailView;
use crate::views::pull_requests::new::open_new_pull_panel;
#[derive(Default)]
pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>,
ready_requested: bool,
/// Re-requested only when the announced HEAD or the base default changes.
ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>,
}
impl Banners {
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
self.dismissed
.contains(&(status.path.clone(), status.branch.clone()))
}
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
self.dismissed
.insert((status.path.clone(), status.branch.clone()));
}
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head)
}
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
self.ready_requested = true;
self.ready_head = head;
}
pub(super) fn set_statuses(
&mut self,
ready: Vec<CheckoutStatus>,
push: Vec<CheckoutStatus>,
) -> bool {
let changed = ready != self.ready_statuses || push != self.push_statuses;
self.ready_statuses = ready;
self.push_statuses = push;
changed
}
}
impl RepoDetailView {
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.read(cx);
@@ -23,10 +68,7 @@ impl RepoDetailView {
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr);
'status: for status in statuses {
if self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
{
if self.banners.dismissal(&status) {
continue;
}
for pr in &store.pull_requests {
@@ -55,17 +97,15 @@ impl RepoDetailView {
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr);
statuses.into_iter().find(|status| {
!self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
})
statuses
.into_iter()
.find(|status| !self.banners.dismissal(status))
}
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let path = status.path.clone();
let branch = status.branch.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self.store.read(cx).pushing;
@@ -98,7 +138,7 @@ impl RepoDetailView {
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.branch),
.child(branch),
)
.child("has")
.child(
@@ -138,7 +178,7 @@ impl RepoDetailView {
.ghost()
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
this.banners.dismiss(&status);
cx.notify();
})),
),
@@ -213,7 +253,8 @@ impl RepoDetailView {
pub(super) fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let branch = status.branch.clone();
let base = status.base.clone();
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
@@ -244,7 +285,7 @@ impl RepoDetailView {
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.branch),
.child(branch),
)
.child("is")
.child(
@@ -270,7 +311,7 @@ impl RepoDetailView {
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.base),
.child(base),
),
)
.child(
@@ -298,7 +339,7 @@ impl RepoDetailView {
.small()
.ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
this.banners.dismiss(&status);
cx.notify();
})),
),
+3 -3
View File
@@ -475,7 +475,7 @@ impl RepoDetailView {
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let commits_count = self.history.read(cx).commit_count();
let worktree_empty = self.switching_ref || self.worktree.is_none();
let worktree_empty = self.refs.switching_ref || self.worktree.is_none();
h_flex()
.items_center()
@@ -580,7 +580,7 @@ impl RepoDetailView {
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.branch_select)
Combobox::new(&self.refs.branch_select)
.placeholder("Branch")
.appearance(false)
.menu_width(px(200.))
@@ -594,7 +594,7 @@ impl RepoDetailView {
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.tag_select)
Combobox::new(&self.refs.tag_select)
.placeholder("Tag")
.appearance(false)
.menu_width(px(200.))
+11 -61
View File
@@ -3,9 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::Error;
use gix::Repository;
use gpui::prelude::*;
use gpui::{Context, Entity, PathPromptOptions, SharedString, Window};
use gpui_component::combobox::ComboboxState;
use gpui_component::searchable_list::SearchableVec;
use gpui::{Context, PathPromptOptions, SharedString, Window};
use nostr::prelude::Url;
use signed_git::FileCommit;
use signed_state::GitStore;
@@ -185,25 +183,17 @@ impl RepoDetailView {
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
let branches_changed = Self::sync_ref_selector(
&this.branch_select,
&mut this.ref_branches,
let branches_changed = this.refs.set_branches(
branches,
current_branch.map(Into::into),
window,
cx,
);
let tags_changed = Self::sync_ref_selector(
&this.tag_select,
&mut this.ref_tags,
tags,
None,
window,
cx,
);
let tags_changed = this.refs.set_tags(tags, window, cx);
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
@@ -263,50 +253,10 @@ impl RepoDetailView {
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
Self::sync_ref_selector(
&self.branch_select,
&mut self.ref_branches,
branches,
current_branch.map(Into::into),
window,
cx,
);
self.refs
.set_branches(branches, current_branch.map(Into::into), window, cx);
Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx);
}
/// Point a ref selector at `items`, selecting `selected` when given.
fn sync_ref_selector(
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
if !items_changed && !selection_changed {
return false;
}
select.update(cx, |state, cx| {
if items_changed {
state.set_items(SearchableVec::from(items.clone()), window, cx);
}
if let Some(value) = selected
&& (items_changed || selection_changed)
{
state.set_selected_values(std::slice::from_ref(&value), window, cx);
}
});
*cached = items;
true
self.refs.set_tags(tags, window, cx);
}
pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -378,14 +328,15 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI.
// A broken ref must not prevent the explorer from loading.
// Failures degrade to empty selectors.
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
@@ -394,7 +345,6 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
Ok(RepoData {
tree,
+31 -77
View File
@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::path::PathBuf;
use anyhow::Error;
@@ -9,13 +8,12 @@ use gpui::{
SharedString, Subscription, Task, WeakEntity, Window, div,
};
use gpui_component::alert::Alert;
use gpui_component::combobox::{ComboboxEvent, ComboboxState};
use gpui_component::searchable_list::SearchableVec;
use gpui_component::combobox::ComboboxEvent;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, v_flex};
use signed_core::{Announcement, RepoAddr};
use signed_git::FileCommit;
use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore};
use signed_state::{CheckoutsStore, RepoStore};
mod about;
mod actions;
@@ -30,8 +28,10 @@ mod store;
pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel};
use self::banners::Banners;
use self::files::RepoFilesView;
use self::history::RepoHistoryView;
use self::refs::RefSwitcher;
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefKind {
@@ -41,10 +41,6 @@ enum RefKind {
Tag,
}
/// Header actions dispatched by the header dropdown menus.
///
/// `pub(crate)` because the pull-request list panel shares this action set,
/// offering the New-PR and Send-patch actions in its own dropdown.
#[derive(Clone, Action, PartialEq, Eq)]
#[action(namespace = repo, no_json)]
pub(crate) enum RepoAction {
@@ -74,38 +70,15 @@ pub struct RepoDetailView {
loading: bool,
error: Option<SharedString>,
head_commit: Option<FileCommit>,
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Branch names currently in `branch_select`, for cheap no-op detection.
ref_branches: Vec<SharedString>,
/// Tag names currently in `tag_select`, for cheap no-op detection.
ref_tags: Vec<SharedString>,
switching_ref: bool,
refs: RefSwitcher,
/// In-flight loads with an older generation are discarded when they complete.
ref_generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
banner_dismissed: HashSet<(PathBuf, String)>,
/// Whether the ready statuses were requested at all.
ready_requested: bool,
/// The announced HEAD they were last requested with. Re-requested only when
/// the HEAD, the base default, changes, e.g. when the store's first refresh
/// lands.
ready_head: Option<String>,
/// The global checkouts store's ready-to-contribute statuses of this
/// repository, last seen when they drove a render.
///
/// The store notifies on any recompute pass; the observer re-renders this
/// panel only when these slices changed.
ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>,
banners: Banners,
}
impl RepoDetailView {
/// `hint` is an announcement already in hand. It seeds the store's relays
/// and the explorer's clone URLs; without it the panel waits for the store
/// to load the announcement from the local database.
pub fn new(
dock_area: WeakEntity<DockArea>,
addr: RepoAddr,
@@ -136,42 +109,31 @@ impl RepoDetailView {
let checkouts = CheckoutsStore::global(cx);
let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let branch_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let tag_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let refs = RefSwitcher::new(window, cx);
let mut subscriptions = vec![
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Branch, name.clone(), window, cx);
}
}),
cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Tag, name.clone(), window, cx);
}
}),
cx.subscribe_in(
&refs.branch_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Branch, name.clone(), window, cx);
}
},
),
cx.subscribe_in(
&refs.tag_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Tag, name.clone(), window, cx);
}
},
),
];
// The ready-to-contribute and ready-to-push banners are driven by the global checkouts store.
@@ -197,18 +159,10 @@ impl RepoDetailView {
loading: true,
error: None,
head_commit: None,
branch_select,
tag_select,
ref_branches: Vec::new(),
ref_tags: Vec::new(),
switching_ref: false,
refs,
ref_generation: 0,
tasks: Vec::new(),
banner_dismissed: HashSet::new(),
ready_requested: false,
ready_head: None,
ready_statuses: Vec::new(),
push_statuses: Vec::new(),
banners: Banners::default(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
};
+136 -24
View File
@@ -1,12 +1,123 @@
use anyhow::Error;
use gpui::prelude::*;
use gpui::{Context, Entity, SharedString, Window};
use gpui::{App, Context, Entity, SharedString, Window};
use gpui_component::combobox::ComboboxState;
use gpui_component::searchable_list::SearchableVec;
use super::{RefKind, RepoDetailView};
use crate::views::tree::{build_tree_items, sorted_worktree_paths};
pub(super) struct RefSwitcher {
pub(super) branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
pub(super) tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
ref_branches: Vec<SharedString>,
ref_tags: Vec<SharedString>,
pub(super) switching_ref: bool,
}
impl RefSwitcher {
pub(super) fn new(window: &mut Window, cx: &mut App) -> Self {
let branch_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let tag_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
Self {
branch_select,
tag_select,
ref_branches: Vec::new(),
ref_tags: Vec::new(),
switching_ref: false,
}
}
pub(super) fn set_branches(
&mut self,
branches: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(
&self.branch_select,
&mut self.ref_branches,
branches,
selected,
window,
cx,
)
}
pub(super) fn set_tags(
&mut self,
tags: Vec<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
}
fn restore_selection(
&self,
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
previous: &Option<SharedString>,
window: &mut Window,
cx: &mut App,
) {
select.update(cx, |state, cx| match previous {
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
None => state.clear_selection(cx),
});
}
}
fn sync_selector(
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
if !items_changed && !selection_changed {
return false;
}
select.update(cx, |state, cx| {
if items_changed {
state.set_items(SearchableVec::from(items.clone()), window, cx);
}
if let Some(value) = selected
&& (items_changed || selection_changed)
{
state.set_selected_values(std::slice::from_ref(&value), window, cx);
}
});
*cached = items;
true
}
impl RepoDetailView {
pub(super) fn switch_ref<T>(
&mut self,
@@ -17,7 +128,7 @@ impl RepoDetailView {
) where
T: Into<SharedString>,
{
if self.switching_ref {
if self.refs.switching_ref {
return;
}
@@ -26,21 +137,23 @@ impl RepoDetailView {
};
let name = name.into();
let previous_branch = self.branch_select.read(cx).selected_value();
let previous_tag = self.tag_select.read(cx).selected_value();
let previous_branch = self.refs.branch_select.read(cx).selected_value();
let previous_tag = self.refs.tag_select.read(cx).selected_value();
match kind {
RefKind::Branch => {
self.tag_select
self.refs
.tag_select
.update(cx, |state, cx| state.clear_selection(cx));
}
RefKind::Tag => {
self.branch_select
self.refs
.branch_select
.update(cx, |state, cx| state.clear_selection(cx));
}
}
self.switching_ref = true;
self.refs.switching_ref = true;
self.ref_generation += 1;
cx.notify();
@@ -64,9 +177,19 @@ impl RepoDetailView {
Ok(()) => this.reload_worktree(cx),
Err(error) => {
this.error = Some(format!("Failed to check out {name}: {error}").into());
this.switching_ref = false;
this.restore_selection(&this.branch_select, &previous_branch, window, cx);
this.restore_selection(&this.tag_select, &previous_tag, window, cx);
this.refs.switching_ref = false;
this.refs.restore_selection(
&this.refs.branch_select,
&previous_branch,
window,
cx,
);
this.refs.restore_selection(
&this.refs.tag_select,
&previous_tag,
window,
cx,
);
}
}
cx.notify();
@@ -78,19 +201,6 @@ impl RepoDetailView {
self.tasks.push(task);
}
fn restore_selection(
&self,
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
previous: &Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
select.update(cx, |state, cx| match previous {
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
None => state.clear_selection(cx),
});
}
fn reload_worktree(&mut self, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else {
return;
@@ -108,7 +218,8 @@ impl RepoDetailView {
.await;
this.update(cx, |this, cx| {
this.switching_ref = false;
this.refs.switching_ref = false;
match result {
Ok((snapshot, tree, paths)) => {
this.head_commit = snapshot.head_commit;
@@ -130,6 +241,7 @@ impl RepoDetailView {
});
}
}
cx.notify();
})?;
+4 -7
View File
@@ -47,13 +47,13 @@ impl RepoDetailView {
};
let head = self.store.read(cx).head.clone();
let (requested, requested_head) = self.banners.ready_requested_at();
if self.ready_requested && self.ready_head == head {
if requested && requested_head == &head {
return;
}
self.ready_requested = true;
self.ready_head = head.clone();
self.banners.mark_ready_requested(head.clone());
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
@@ -83,9 +83,6 @@ impl RepoDetailView {
let ready_statuses = checkouts.ready_statuses_of(&addr);
let push_statuses = checkouts.push_statuses_of(&addr);
let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses;
self.ready_statuses = ready_statuses;
self.push_statuses = push_statuses;
changed
self.banners.set_statuses(ready_statuses, push_statuses)
}
}