From 93d96ca9ee2761328fa23d5c299a9e64e417e56d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:54:17 +0700 Subject: [PATCH] . --- crates/workspace/src/views/repo/banners.rs | 73 +++++++--- crates/workspace/src/views/repo/header.rs | 6 +- crates/workspace/src/views/repo/loading.rs | 72 ++-------- crates/workspace/src/views/repo/mod.rs | 108 ++++---------- crates/workspace/src/views/repo/refs.rs | 160 +++++++++++++++++---- crates/workspace/src/views/repo/store.rs | 11 +- 6 files changed, 242 insertions(+), 188 deletions(-) diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index db16bf0..86725b1 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -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, + ready_statuses: Vec, + push_statuses: Vec, +} + +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) { + (self.ready_requested, &self.ready_head) + } + + pub(super) fn mark_ready_requested(&mut self, head: Option) { + self.ready_requested = true; + self.ready_head = head; + } + + pub(super) fn set_statuses( + &mut self, + ready: Vec, + push: Vec, + ) -> 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 { 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) -> Option { 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) -> Option { 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(); })), ), diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index f2f82d5..82999b1 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -475,7 +475,7 @@ impl RepoDetailView { fn render_header_tabs(&self, cx: &mut Context) -> 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.)) diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index e0726d1..0fd4264 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -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 = branches.iter().map(Into::into).collect(); let tags: Vec = 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 = branches.into_iter().map(Into::into).collect(); let tags: Vec = 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>>, - cached: &mut Vec, - items: Vec, - selected: Option, - window: &mut Window, - cx: &mut Context, - ) -> 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) { @@ -378,14 +328,15 @@ fn load_repo_data(repo: &Repository) -> Result { 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 { ), None => (Vec::new(), Vec::new(), None), }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); Ok(RepoData { tree, diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index d30cdb5..18f772f 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -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, head_commit: Option, - branch_select: Entity>>, - tag_select: Entity>>, - /// Branch names currently in `branch_select`, for cheap no-op detection. - ref_branches: Vec, - /// Tag names currently in `tag_select`, for cheap no-op detection. - ref_tags: Vec, - switching_ref: bool, + refs: RefSwitcher, /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, tasks: Vec>>, _subscriptions: Vec, - /// `(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, - /// 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, - push_statuses: Vec, + 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, 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::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - - let tag_select = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::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, }; diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index cc5ab79..c48a52a 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -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>>, + pub(super) tag_select: Entity>>, + ref_branches: Vec, + ref_tags: Vec, + 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::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::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, + selected: Option, + 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, + 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>>, + previous: &Option, + 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>>, + cached: &mut Vec, + items: Vec, + selected: Option, + 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( &mut self, @@ -17,7 +128,7 @@ impl RepoDetailView { ) where T: Into, { - 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>>, - previous: &Option, - window: &mut Window, - cx: &mut Context, - ) { - 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) { 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(); })?; diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 77bf495..8308984 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -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) } }