From 26653fa1245180b868d2afd9155056edb4020249 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 16:14:33 +0700 Subject: [PATCH] . --- crates/workspace/src/views/repo/actions.rs | 117 +- crates/workspace/src/views/repo/banners.rs | 306 +--- crates/workspace/src/views/repo/header.rs | 786 --------- crates/workspace/src/views/repo/loading.rs | 360 ---- crates/workspace/src/views/repo/mod.rs | 1820 +++++++++++++++++++- crates/workspace/src/views/repo/refs.rs | 198 +-- crates/workspace/src/views/repo/store.rs | 88 - 7 files changed, 1807 insertions(+), 1868 deletions(-) delete mode 100644 crates/workspace/src/views/repo/header.rs delete mode 100644 crates/workspace/src/views/repo/loading.rs delete mode 100644 crates/workspace/src/views/repo/store.rs diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index cc4ffb9..e48c855 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -1,131 +1,16 @@ -use std::path::PathBuf; use std::sync::Arc; -use anyhow::Error; use dock::{DockArea, add_center_panel, panel_handle}; use gpui::prelude::*; -use gpui::{App, Context, Entity, WeakEntity, Window}; +use gpui::{App, Entity, WeakEntity, Window}; use gpui_base::dock::PanelView; use nostr::prelude::EventId; use signed_core::{Announcement, RepoAddr}; use signed_state::RepoStore; use super::RepoDetailView; -use crate::views::issues::IssuesView; use crate::views::issues::detail::IssueDetailView; -use crate::views::pull_requests::PullRequestsView; use crate::views::pull_requests::detail::PullRequestDetailView; -use crate::views::repo::init_dialog; - -impl RepoDetailView { - pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - self.error = None; - cx.notify(); - - let task = self.store.update(cx, |store, cx| store.push_repository(cx)); - self.tasks.push(task); - } - - pub(super) fn push_unpushed_checkout( - &mut self, - path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let store = self.store.clone(); - - if store.read(cx).pushing { - return; - } - - self.error = None; - cx.notify(); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let push = this.update_in(cx, |_this, _window, cx| { - store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) - })?; - - if let Ok(()) = push.await { - this.update_in(cx, |this, window, cx| { - this.load_repo(window, cx); - })?; - } - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Delete the repository from nostr, announcement, state and activity. - pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let task = self - .store - .update(cx, |store, cx| store.delete_repository(cx)); - self.tasks.push(task); - } - - pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - if self.store.read(cx).addr().is_none() { - return; - } - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let store = self.store.clone(); - let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - if self.store.read(cx).addr().is_none() { - return; - } - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let store = self.store.clone(); - let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the upstream repository, the `u` tag of this fork's announcement. - /// - /// The announcement may not be in the local database yet. The panel opens - /// from the address and fills in when the store loads it; the store's - /// `subscribe_remote` fetches it from the bootstrap relays. - pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self - .announcement(cx) - .and_then(|announcement| announcement.upstream.as_ref()) - .and_then(|upstream| upstream.addr.clone()) - else { - return; - }; - - open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); - } - - /// Open the dialog that publishes the local repository to NIP-34. - pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.store.read(cx).path.clone() else { - return; - }; - let view = cx.entity().downgrade(); - init_dialog::open(local_path, view, window, cx); - } -} /// Open repository as a panel in the dock's center. pub(crate) fn open_repo_panel( diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index 86725b1..06a4967 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -1,17 +1,7 @@ use std::collections::HashSet; use std::path::PathBuf; -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{AnyElement, App, Context, SharedString, div, transparent_white}; -use gpui_base::Disableable; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, h_flex}; -use signed_core::RepoStatus; -use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; - -use super::RepoDetailView; -use crate::views::pull_requests::new::open_new_pull_panel; +use signed_state::CheckoutStatus; #[derive(Default)] pub(super) struct Banners { @@ -54,297 +44,3 @@ impl Banners { changed } } - -impl RepoDetailView { - fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.read(cx); - let addr = store.addr()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if store.is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); - - 'status: for status in statuses { - if self.banners.dismissal(&status) { - continue; - } - for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { - continue 'status; - } - } - return Some(status); - } - - None - } - - /// The first checkout of this owned repository with unpushed commits. - /// - /// Not dismissed in this panel. - fn push_suggestion(&self, cx: &App) -> Option { - let store = self.store.read(cx); - let addr = store.addr()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if !store.is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); - - 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 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; - - let commits = if status.ahead == 1 { - SharedString::from("1 commit") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().info) - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(branch), - ) - .child("has") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(commits), - ) - .child("ready to push"), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("push-checkout-banner") - .icon(IconName::ArrowUp) - .label("Push") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _event, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), - ) - .child( - Button::new("close-repo") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banners.dismiss(&status); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.read(cx); - let warning = store.last_push_warning.clone()?; - let pushing = store.pushing; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_start() - .justify_between() - .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) - .child( - h_flex() - .gap_2() - .min_w_0() - .flex_1() - .items_start() - .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().warning) - .child(SharedString::from(warning)), - ), - ) - .child( - h_flex() - .gap_1() - .flex_shrink_0() - .child( - Button::new("republish-after-partial-push") - .icon(CustomIconName::Init) - .label("Republish") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(|this, _event, window, cx| { - this.push_repository(window, cx); - })), - ) - .child( - Button::new("dismiss-push-warning") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(|this, _ev, _window, cx| { - this.store.update(cx, |store, _| { - store.last_push_warning = None; - }); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { - let status = self.ready_suggestion(cx)?; - let branch = status.branch.clone(); - let base = status.base.clone(); - - let commits = if status.ahead == 1 { - SharedString::from("1 commit") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().info) - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(branch), - ) - .child("is") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(commits), - ) - .child("ahead of") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(base), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("create-pr-from-banner") - .icon(IconName::Plus) - .label("Create") - .small() - .info() - .on_click(cx.listener(|this, _event, window, cx| { - open_new_pull_panel( - this.dock_area.clone(), - this.store.clone(), - window, - cx, - ); - })), - ) - .child( - Button::new("dismiss-ready-banner") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banners.dismiss(&status); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } -} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs deleted file mode 100644 index 82999b1..0000000 --- a/crates/workspace/src/views/repo/header.rs +++ /dev/null @@ -1,786 +0,0 @@ -use std::collections::HashSet; -use std::rc::Rc; - -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, relative}; -use gpui_base::{Button as BaseButton, Disableable, Popover}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::Combobox; -use gpui_component::menu::{DropdownMenu, PopupMenu}; -use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, -}; -use nostr::nips::nip19::Nip19Coordinate; -use nostr::prelude::{RelayUrl, ToBech32}; -use signed_core::Announcement; -use signed_state::{Backend, ProfileStore, RepoListStore}; -use signed_ui::{ - CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, - ref_selector_trigger, -}; - -use super::{RepoAction, RepoDetailView, repo_display_name}; -use crate::views::issues::open_new_issue_dialog; -use crate::views::pull_requests::new::open_new_pull_panel; -use crate::views::repo::about::open_about_dialog; -use crate::views::send_patch::open_send_patch_panel; - -impl RepoDetailView { - pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.store.read(cx).addr().is_none() { - return self.render_local_header(cx); - } - - let store = self.store.read(cx); - let issue_count = SharedString::from(store.issue_count().to_string()); - let pr_count = SharedString::from(store.pull_request_count().to_string()); - - // Busy flags are owned by the store; observers re-render on their changes. - let pushing = store.pushing; - let cloning = store.cloning; - - let Some(source) = store.announcement.as_ref() else { - return div().into_any_element(); - }; - - let nip05 = ProfileStore::global(cx) - .read(cx) - .get(&source.owner) - .metadata() - .nip05 - .clone() - .filter(|nip05| !nip05.trim().is_empty()); - - let announcement = Rc::new(source.clone()); - let share = Rc::new(ShareTargets::from_announcement(&announcement)); - - let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); - let ngit_command = SharedString::from(format!("git clone {nostr_url}")); - let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); - let git_commands = Rc::new(announcement.clone_urls()); - - let name = repo_display_name(self.store.read(cx)); - let description = announcement.description(); - let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); - - v_flex() - .on_action( - cx.listener(|this, action: &RepoAction, window, cx| match action { - RepoAction::NewIssue => { - open_new_issue_dialog(this.store.clone(), window, cx); - } - RepoAction::NewPR => { - open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); - } - RepoAction::SendPatch => { - open_send_patch_panel( - this.dock_area.clone(), - this.store.clone(), - window, - cx, - ); - } - RepoAction::About => { - if let Some(announcement) = this.announcement(cx) { - open_about_dialog(announcement.clone(), window, cx); - } - } - RepoAction::Push => this.push_repository(window, cx), - RepoAction::Delete => this.delete_repository(window, cx), - }), - ) - .p_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(description), - ) - .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) - .child( - h_flex() - .mt_2() - .w_full() - .gap_0p5() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .font_semibold() - .child("Maintainers:"), - ) - .child(self.render_maintainers(cx)), - ), - ) - .child( - h_flex() - .flex_none() - .gap_2() - .justify_end() - .child( - DropdownButton::new("issues") - .action( - BaseButton::new("issues-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new(CustomIconName::GitIssueDone)) - .child("Issues") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(issue_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_issue_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New issue") - }) - }), - ) - .child( - DropdownButton::new("prs") - .action( - BaseButton::new("prs-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new( - CustomIconName::GitPullRequest, - )) - .child("Pull Requests") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(pr_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_pull_request_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New Pull Request") - }) - .menu_element( - Box::new(RepoAction::SendPatch), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::File)) - .child("Send Patch") - }, - ) - }), - ) - .child( - DropdownButton::new("share") - .action( - Button::new("link") - .icon(IconName::Copy) - .tooltip("Copy ID") - .secondary() - .on_click({ - let naddr = share.naddr.clone(); - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(naddr.clone()), - ); - } - }), - ) - .dropdown_menu(move |menu, _, _| share.menu(menu)), - ) - .child( - Button::new("repo-menu-open") - .icon(IconName::EllipsisVertical) - .tooltip("Repository management") - .compact() - .secondary() - .loading(pushing) - .disabled(pushing) - .dropdown_menu(move |menu, _, cx| { - let backend = Backend::global(cx); - let current_user = backend.read(cx).current_user(); - let owner = current_user == Some(announcement.owner); - - let menu = menu.menu_element( - Box::new(RepoAction::About), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Info)) - .child("About") - }, - ); - - if owner { - menu.menu_element(Box::new(RepoAction::Push), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(CustomIconName::Init)) - .child("Republish") - }) - .separator() - .menu_element(Box::new(RepoAction::Delete), |_, cx| { - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().danger) - .child(Icon::new(IconName::Delete)) - .child("Delete") - }) - } else { - menu - } - }), - ) - .child({ - let view = cx.entity(); - let ngit_command = ngit_command.clone(); - let nak_command = nak_command.clone(); - let git_commands = git_commands.clone(); - - Popover::new("clone") - .anchor(Anchor::TopRight) - .trigger( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .loading(cloning) - .disabled(cloning) - .primary(), - ) - .content(move |_, _window, cx| { - let state = cx.entity(); - let ngit_row = copy_row("copy-ngit", &ngit_command, cx); - let nak_row = copy_row("copy-nak", &nak_command, cx); - - v_flex() - .w(px(440.)) - .mt_1() - .p_3() - .gap_4() - .popover_style(cx) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with ngit"), - ) - .child(ngit_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with nak"), - ) - .child(nak_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Grasp Servers"), - ) - .when(!git_commands.is_empty(), |this| { - this.children( - git_commands.iter().enumerate().map( - |(ix, cmd)| { - copy_row( - format!("copy-git-{ix}"), - cmd, - cx, - ) - }, - ), - ) - }) - .when(git_commands.is_empty(), |this| { - this.child( - div() - .text_xs() - .child("No git clone urls."), - ) - }), - ) - .child(div().h_px().w_full().bg(cx.theme().border)) - .child( - h_flex().gap_1().justify_end().child( - Button::new("download") - .icon(CustomIconName::GitClone) - .label("Download") - .primary() - .on_click(move |_event, window, cx| { - state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - view.update(cx, |this, cx| { - this.clone_to_folder(window, cx); - }); - }), - ), - ) - }) - }), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = repo_display_name(self.store.read(cx)); - let path = self - .store - .read(cx) - .path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(); - let avatar = PixelAvatar::new(path.clone()); - - v_flex() - .px_4() - .pb_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(path), - ), - ) - .child( - Button::new("init") - .icon(CustomIconName::Init) - .label("Initialize on Nostr") - .primary() - .tooltip("Publish this repository to Nostr") - .on_click(cx.listener(|this, _event, window, cx| { - this.open_init_dialog(window, cx); - })), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.history.read(cx).commit_count(); - let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); - - h_flex() - .items_center() - .gap_2() - .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"), - ) - .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"), - ) - .when_some(commits_count, |this, count| { - this.child(CountBadge::new(count)) - }) - .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(); - })), - ) - .child( - h_flex() - .flex_1() - .gap_2() - .justify_end() - .child( - Button::new("enc") - .ghost() - .when_some(self.head_commit.as_ref(), |this, commit| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(&commit.id)), - ) - .child( - div() - .max_w(px(200.)) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .text_xs() - .child(SharedString::from(&commit.summary)), - ) - }) - .tooltip( - self.head_commit - .as_ref() - .map_or_else(SharedString::default, |commit| { - commit.summary.clone().into() - }), - ) - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(commit) = &this.head_commit { - let id = commit.id.clone(); - this.history.update(cx, |history, cx| { - history.open_commit_diff(&id, window, cx) - }); - } - })), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.refs.branch_select) - .placeholder("Branch") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) - }), - ), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.refs.tag_select) - .placeholder("Tag") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::Tag, cx) - }), - ), - ), - ) - .into_any_element() - } - - fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let Some(announcement) = self.announcement(cx) else { - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - let mut seen = HashSet::new(); - let rest: Vec<_> = announcement - .maintainers - .iter() - .copied() - .filter(|key| key != &announcement.owner && seen.insert(*key)) - .collect(); - - let owner = profile_store.read(cx).get(&announcement.owner); - let owner_name = owner.name(); - let owner_picture = owner.picture(); - - h_flex() - .w_full() - .gap_3() - .child( - Button::new("maintainers").compact().ghost().child( - h_flex() - .gap_2() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), - ) - .when(!rest.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("+{}", rest.len()))), - ) - }), - ), - ) - .into_any_element() - } -} - -fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { - let owner = announcement.owner; - let user = nip05 - .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap()); - - let mut url = format!("nostr://{user}"); - if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { - url.push('/'); - url.push_str(hint); - } - url.push('/'); - url.push_str(&announcement.id); - - SharedString::from(url) -} - -fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { - let upstream = announcement.upstream.as_ref()?; - - let (label, clickable) = match &upstream.addr { - Some(addr) => { - // Prefer the upstream's display name when its announcement is known locally. - // Fall back to its repository id otherwise. - let name = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == *addr) - .map(|a| { - a.name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(a.id.clone())) - }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); - (SharedString::from(format!("Forked from {name}")), true) - } - None => (SharedString::from(upstream.display().as_str()), false), - }; - - let row = h_flex() - .gap_1() - .items_center() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::GitBranch).small()) - .child(div().whitespace_nowrap().text_ellipsis().child(label)); - - Some(if clickable { - row.id("fork-upstream") - .cursor_pointer() - .hover(|this| this.text_color(cx.theme().foreground)) - .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) - .into_any_element() - } else { - row.into_any_element() - }) -} - -struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - naddr: String, - /// Hex ID of the announcement event itself. - event_id: String, - /// NIP-34 coordinate `30617::`. - coordinate: String, - /// `https://gitworkshop.dev/` - gitworkshop: String, - /// `https://ditto.pub/` - ditto: String, -} - -impl ShareTargets { - fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) - .to_bech32() - .expect("a complete coordinate always encodes to naddr"); - - Self { - naddr: naddr.clone(), - event_id: announcement.event_id.to_bech32().unwrap(), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - fn menu(&self, menu: PopupMenu) -> PopupMenu { - menu.min_w(px(340.)) - .item(menu_copy_row( - "copy-gitworkshop", - "GitWorkshop", - truncate_naddr_link(&self.gitworkshop, 4), - self.gitworkshop.clone(), - )) - .item(menu_copy_row( - "copy-ditto", - "Ditto", - truncate_naddr_link(&self.ditto, 4), - self.ditto.clone(), - )) - .item(menu_copy_row( - "copy-event-id", - "Event ID", - middle_truncate(&self.event_id, 10, 10), - self.event_id.clone(), - )) - .item(menu_copy_row( - "copy-coordinate", - "Coordinate", - middle_truncate(&self.coordinate, 10, 10), - self.coordinate.clone(), - )) - } -} - -fn truncate_naddr_link(url: &str, tail: usize) -> String { - let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { - return url.to_string(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs deleted file mode 100644 index 0fd4264..0000000 --- a/crates/workspace/src/views/repo/loading.rs +++ /dev/null @@ -1,360 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Error; -use gix::Repository; -use gpui::prelude::*; -use gpui::{Context, PathPromptOptions, SharedString, Window}; -use nostr::prelude::Url; -use signed_git::FileCommit; -use signed_state::GitStore; - -use super::RepoDetailView; -use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; - -struct RepoData { - tree: Vec, - entries: Vec, - readme_path: Option, - readme: Option>, - worktree: Option, - branches: Vec, - tags: Vec, - current_branch: Option, - head_commit: Option, -} - -impl RepoDetailView { - /// Load the repository and populate the file explorer. - pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.error = None; - cx.notify(); - - let (addr, announcement, local_path) = { - let store = self.store.read(cx); - ( - store.addr().cloned(), - store.announcement.clone(), - store.path.clone(), - ) - }; - - // Local repositories live on disk at their scan path. - // No clone step or network refresh applies here. - if addr.is_none() { - self.repo_started = true; - - let Some(local_path) = local_path else { - return; - }; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let data = cx - .background_spawn(async move { - let repo = gix::open(&local_path)?; - load_repo_data(&repo) - }) - .await; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - - return; - } - - let Some(announcement) = announcement else { - return; - }; - self.repo_started = true; - - let cache = GitStore::global(cx).cache().clone(); - let addr = announcement.addr(); - let clone_urls: Vec = announcement.clone.clone(); - - // Captured before the loads start. - // A branch/tag switch bumps the generation, discarding the refresh below. - let refresh_generation = self.ref_generation; - - let disk = { - let cache = cache.clone(); - let addr = addr.clone(); - cx.background_spawn(async move { - match cache.open(&addr)? { - Some(repo) => Ok(Some(load_repo_data(&repo)?)), - None => Ok(None), - } - }) - }; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let disk = disk.await; - let had_clone = matches!(&disk, Ok(Some(_))); - - let data = match disk { - Ok(Some(data)) => Ok(data), - Ok(None) => { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - load_repo_data(&repo) - }) - .await - } - Err(error) => Err(error), - }; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - // Refresh the clone from the network in the background. - // When it completes, update the refs and commit list. - // Loads started before a branch/tag switch are discarded via the generation. - if !had_clone { - return Ok(()); - } - - let refresh = { - let cache = cache.clone(); - let addr = addr.clone(); - - cx.background_spawn(async move { - let Some(repo) = cache.open(&addr)? else { - return Ok::<_, Error>(None); - }; - - // Best-effort, a fetch failure, e.g. offline, keeps the cached state. - // The state is already shown. - signed_git::fetch_all(&repo).ok(); - - let worktree = repo.workdir().map(Path::to_path_buf); - // A fetch never moves a mirror's local branches. - // A push landing on the grasp servers would never show up. - // That covers own repo pushes from a checkout and updates fetched here. - // Fast-forward branches from the remote, like `git pull --ff-only`. - // Only the checked-out branch's worktree can change on disk. - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - None => (Vec::new(), Vec::new()), - }; - - let current_branch = signed_git::current_branch(&repo).unwrap_or(None); - let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - - Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) - }) - } - .await; - - this.update_in(cx, |this, window, cx| { - if refresh_generation != this.ref_generation { - return; - } - - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); - - let branches_changed = this.refs.set_branches( - branches, - current_branch.map(Into::into), - 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; - - if head_changed { - this.history.update(cx, |history, cx| history.reload(cx)); - } - - if moved { - this.catch_up_worktree(cx); - } - - if branches_changed || tags_changed || head_changed { - cx.notify(); - } - } - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { - log::debug!("repo detail: apply_repo_data"); - let RepoData { - tree, - entries, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - } = data; - - let Some(worktree) = worktree else { - self.error = Some("Repository has no worktree".into()); - return; - }; - - self.worktree = Some(worktree.clone()); - self.head_commit = head_commit; - - self.files.update(cx, |files, cx| { - files.set_worktree(worktree.clone()); - files.apply_entries(tree, sorted_worktree_paths(&entries), cx); - files.set_readme(readme_path, readme, cx); - }); - - self.history.update(cx, |history, cx| { - history.set_worktree(Some(worktree)); - history.reload(cx); - }); - - let branches: Vec = branches.into_iter().map(Into::into).collect(); - let tags: Vec = tags.into_iter().map(Into::into).collect(); - - self.refs - .set_branches(branches, current_branch.map(Into::into), window, cx); - - self.refs.set_tags(tags, window, cx); - } - - pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let store = self.store.clone(); - - let name = { - let Some(announcement) = self.announcement(cx) else { - return; - }; - let addr = announcement.addr(); - // Directory name, the display name falling back to the repo id. - let name = announcement - .name - .as_ref() - .map(|name| name.to_string()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); - let name = signed_git::sanitize_path_component(&name); - - if name.is_empty() { - "repository".to_owned() - } else { - name - } - }; - - let prompt = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Clone".into()), - }); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder. - // A cancel or picker failure resolves to anything else. - let picked = match prompt.await { - Ok(Ok(Some(mut paths))) => paths.pop(), - _ => None, - }; - let Some(folder) = picked else { - return Ok(()); - }; - - let destination = folder.join(&name); - let destination_for_open = destination.clone(); - - // The store owns the clone, its busy flag and error reporting. - let clone = this.update_in(cx, |_this, _window, cx| { - store.update(cx, |store, cx| store.clone_to_folder(destination, cx)) - })?; - - // Reveal the new clone in the system file manager on success. - // Failures already surfaced in the store's error banner. - if let Ok(()) = clone.await { - this.update_in(cx, |_this, _window, cx| { - cx.open_with_system(&destination_for_open); - })?; - } - - Ok(()) - }); - - self.tasks.push(task); - } -} - -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); - 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(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - - Ok(RepoData { - tree, - entries, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 18f772f..6673c54 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -1,30 +1,46 @@ -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::rc::Rc; use anyhow::Error; -use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use assets::CustomIconName; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; +use gix::Repository; use gpui::prelude::*; use gpui::{ - Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, - SharedString, Subscription, Task, WeakEntity, Window, div, + Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, PathPromptOptions, Render, SharedString, Subscription, Task, WeakEntity, Window, + div, px, relative, transparent_white, }; +use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; -use gpui_component::combobox::ComboboxEvent; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::combobox::{Combobox, ComboboxEvent}; +use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, v_flex}; -use signed_core::{Announcement, RepoAddr}; +use gpui_component::{ + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, +}; +use nostr::nips::nip19::Nip19Coordinate; +use nostr::prelude::{RelayUrl, ToBech32, Url}; +use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::FileCommit; -use signed_state::{CheckoutsStore, RepoStore}; +use signed_state::{ + Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, + RepoListStore, RepoStore, pr_proposes_checkout, +}; +use signed_ui::{ + CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, + ref_selector_trigger, +}; mod about; mod actions; mod banners; mod files; -mod header; mod history; mod init_dialog; -mod loading; mod refs; -mod store; pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; @@ -32,6 +48,12 @@ use self::banners::Banners; use self::files::RepoFilesView; use self::history::RepoHistoryView; use self::refs::RefSwitcher; +use crate::views::issues::{IssuesView, open_new_issue_dialog}; +use crate::views::pull_requests::PullRequestsView; +use crate::views::pull_requests::new::open_new_pull_panel; +use crate::views::repo::about::open_about_dialog; +use crate::views::send_patch::open_send_patch_panel; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { @@ -56,26 +78,35 @@ pub struct RepoDetailView { focus_handle: FocusHandle, dock_area: WeakEntity, store: Entity, - /// A repository opened by address alone starts without an announcement; the - /// store observer starts the load once the first one lands. + /// A repository opened by address alone starts without an announcement. repo_started: bool, /// The Files tab, which owns the explorer, previews and the per-file commit map. files: Entity, /// The checked-out worktree path, shared by the Files tab and the commit list. worktree: Option, - /// 0 = Files tree, 1 = Commits. + /// The active tab, either 0 (Files tree) or 1 (Commits). active_tab: usize, - /// The Commits tab, which owns the commit list and the commit diff panels. history: Entity, loading: bool, error: Option, head_commit: Option, refs: RefSwitcher, - /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, + banners: Banners, tasks: Vec>>, _subscriptions: Vec, - banners: Banners, +} + +struct RepoData { + tree: Vec, + entries: Vec, + readme_path: Option, + readme: Option>, + worktree: Option, + branches: Vec, + tags: Vec, + current_branch: Option, + head_commit: Option, } impl RepoDetailView { @@ -171,6 +202,87 @@ impl RepoDetailView { view } + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + let path = self.store.read(cx).path.clone(); + + if let Some(path) = path { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + // The new address needs its ready-to-contribute statuses requested. + self.refresh_ready_statuses(cx); + cx.notify(); + } + + pub(super) fn attach_store( + &mut self, + store: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.observe_in(store, window, |this, store, window, cx| { + this.refresh_ready_statuses(cx); + if !this.repo_started && store.read(cx).announcement.is_some() { + this.load_repo(window, cx); + } + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let head = self.store.read(cx).head.clone(); + let (requested, requested_head) = self.banners.ready_requested_at(); + + if requested && requested_head == &head { + return; + } + + self.banners.mark_ready_requested(head.clone()); + + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| self.store.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + // The ready statuses keep the fast poll running while the panel is open. + // The sidebar's push watch alone polls slower. + store.request_statuses(&addr, head, cx); + + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return false; + }; + + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + self.banners.set_statuses(ready_statuses, push_statuses) + } + /// The latest announcement of the repository, /// /// `None` while local-only or until the store's first pass loads it. @@ -178,6 +290,1515 @@ impl RepoDetailView { self.store.read(cx).announcement.as_ref() } + /// Load the repository and populate the file explorer. + pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let (addr, announcement, local_path) = { + let store = self.store.read(cx); + ( + store.addr().cloned(), + store.announcement.clone(), + store.path.clone(), + ) + }; + + // Local repositories live on disk at their scan path. + // No clone step or network refresh applies here. + if addr.is_none() { + self.repo_started = true; + + let Some(local_path) = local_path else { + return; + }; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let data = cx + .background_spawn(async move { + let repo = gix::open(&local_path)?; + load_repo_data(&repo) + }) + .await; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + + return; + } + + let Some(announcement) = announcement else { + return; + }; + self.repo_started = true; + + let cache = GitStore::global(cx).cache().clone(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); + + // Captured before the loads start. + // A branch/tag switch bumps the generation, discarding the refresh below. + let refresh_generation = self.ref_generation; + + let disk = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + match cache.open(&addr)? { + Some(repo) => Ok(Some(load_repo_data(&repo)?)), + None => Ok(None), + } + }) + }; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let disk = disk.await; + let had_clone = matches!(&disk, Ok(Some(_))); + + let data = match disk { + Ok(Some(data)) => Ok(data), + Ok(None) => { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + load_repo_data(&repo) + }) + .await + } + Err(error) => Err(error), + }; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + // Refresh the clone from the network in the background. + // When it completes, update the refs and commit list. + // Loads started before a branch/tag switch are discarded via the generation. + if !had_clone { + return Ok(()); + } + + let refresh = { + let cache = cache.clone(); + let addr = addr.clone(); + + cx.background_spawn(async move { + let Some(repo) = cache.open(&addr)? else { + return Ok::<_, Error>(None); + }; + + // Best-effort, a fetch failure, e.g. offline, keeps the cached state. + // The state is already shown. + signed_git::fetch_all(&repo).ok(); + + let worktree = repo.workdir().map(Path::to_path_buf); + // A fetch never moves a mirror's local branches. + // A push landing on the grasp servers would never show up. + // That covers own repo pushes from a checkout and updates fetched here. + // Fast-forward branches from the remote, like `git pull --ff-only`. + // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { + Some(worktree) => { + signed_git::fast_forward_branches(worktree).unwrap_or(false) + } + None => false, + }; + + let (branches, tags) = match &worktree { + Some(_) => ( + signed_git::repo_branches(&repo).unwrap_or_default(), + signed_git::repo_tags(&repo).unwrap_or_default(), + ), + None => (Vec::new(), Vec::new()), + }; + + let current_branch = signed_git::current_branch(&repo).unwrap_or(None); + let head_commit = signed_git::head_commit(&repo).unwrap_or(None); + + Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) + }) + } + .await; + + this.update_in(cx, |this, window, cx| { + if refresh_generation != this.ref_generation { + return; + } + + if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { + let branches: Vec = branches.iter().map(Into::into).collect(); + let tags: Vec = tags.iter().map(Into::into).collect(); + + let branches_changed = this.refs.set_branches( + branches, + current_branch.map(Into::into), + 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; + + if head_changed { + this.history.update(cx, |history, cx| history.reload(cx)); + } + + if moved { + this.catch_up_worktree(cx); + } + + if branches_changed || tags_changed || head_changed { + cx.notify(); + } + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + log::debug!("repo detail: apply_repo_data"); + let RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + } = data; + + let Some(worktree) = worktree else { + self.error = Some("Repository has no worktree".into()); + return; + }; + + self.worktree = Some(worktree.clone()); + self.head_commit = head_commit; + + self.files.update(cx, |files, cx| { + files.set_worktree(worktree.clone()); + files.apply_entries(tree, sorted_worktree_paths(&entries), cx); + files.set_readme(readme_path, readme, cx); + }); + + self.history.update(cx, |history, cx| { + history.set_worktree(Some(worktree)); + history.reload(cx); + }); + + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + + self.refs + .set_branches(branches, current_branch.map(Into::into), window, cx); + + self.refs.set_tags(tags, window, cx); + } + + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let store = self.store.clone(); + + let name = { + let Some(announcement) = self.announcement(cx) else { + return; + }; + let addr = announcement.addr(); + // Directory name, the display name falling back to the repo id. + let name = announcement + .name + .as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| addr.identifier.clone()); + let name = signed_git::sanitize_path_component(&name); + + if name.is_empty() { + "repository".to_owned() + } else { + name + } + }; + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Clone".into()), + }); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(folder) = picked else { + return Ok(()); + }; + + let destination = folder.join(&name); + let destination_for_open = destination.clone(); + + // The store owns the clone, its busy flag and error reporting. + let clone = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.clone_to_folder(destination, cx)) + })?; + + // Reveal the new clone in the system file manager on success. + // Failures already surfaced in the store's error banner. + if let Ok(()) = clone.await { + this.update_in(cx, |_this, _window, cx| { + cx.open_with_system(&destination_for_open); + })?; + } + + Ok(()) + }); + + self.tasks.push(task); + } + + fn switch_ref(&mut self, kind: RefKind, name: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + if self.refs.switching_ref { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let name = name.into(); + 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.refs + .tag_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + RefKind::Tag => { + self.refs + .branch_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + } + + self.refs.switching_ref = true; + self.ref_generation += 1; + cx.notify(); + + let checkout_name = name.clone(); + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + match kind { + RefKind::Branch => { + signed_git::worktree_checkout_branch(&worktree, &checkout_name) + } + RefKind::Tag => { + signed_git::worktree_checkout_tag(&worktree, &checkout_name) + } + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => this.reload_worktree(cx), + Err(error) => { + this.error = Some(format!("Failed to check out {name}: {error}").into()); + 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(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn reload_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + // Build the tree off the main thread, like [`Self::load_repo`]. + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + this.refs.switching_ref = false; + + match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit; + let readme_path = snapshot.readme_path; + let readme = snapshot.readme; + this.files.update(cx, |files, cx| { + files.clear_previews(); + files.apply_entries(tree, paths, cx); + files.set_readme(readme_path, readme, cx); + }); + this.history.update(cx, |history, cx| history.reload(cx)); + } + Err(error) => { + this.error = Some(error.to_string().into()); + this.head_commit = None; + // The tree may show files that no longer exist. + this.files.update(cx, |files, cx| { + files.apply_entries(Vec::new(), Vec::new(), cx); + }); + } + } + + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. + pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + match result { + Ok((snapshot, tree, paths)) => { + let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) + != this.head_commit.as_ref().map(|c| &c.id); + + let files_changed = this + .files + .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); + + // A fast-forward of a branch other than the checked-out + // one leaves the worktree untouched. Rebuilding the tree + // and re-parsing the README would flash the panel for + // nothing, so it is a no-op. + if !head_changed && !files_changed { + log::debug!("repo detail catch_up_worktree: no-op"); + return; + } + + this.head_commit = snapshot.head_commit; + + if head_changed { + this.history.update(cx, |history, cx| history.reload(cx)); + } + + cx.notify(); + } + Err(error) => { + this.error = Some(error.to_string().into()); + cx.notify(); + } + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + self.error = None; + cx.notify(); + + let task = self.store.update(cx, |store, cx| store.push_repository(cx)); + self.tasks.push(task); + } + + pub(super) fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + if let Ok(()) = push.await { + this.update_in(cx, |this, window, cx| { + this.load_repo(window, cx); + })?; + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Delete the repository from nostr, announcement, state and activity. + pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let task = self + .store + .update(cx, |store, cx| store.delete_repository(cx)); + self.tasks.push(task); + } + + pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; + } + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; + } + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + /// Open the upstream repository, the `u` tag of this fork's announcement. + /// + /// The announcement may not be in the local database yet. The panel opens + /// from the address and fills in when the store loads it; the store's + /// `subscribe_remote` fetches it from the bootstrap relays. + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { + return; + }; + + open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); + } + + /// Open the dialog that publishes the local repository to NIP-34. + pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.store.read(cx).path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } + + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.store.read(cx).addr().is_none() { + return self.render_local_header(cx); + } + + let store = self.store.read(cx); + let issue_count = SharedString::from(store.issue_count().to_string()); + let pr_count = SharedString::from(store.pull_request_count().to_string()); + + // Busy flags are owned by the store; observers re-render on their changes. + let pushing = store.pushing; + let cloning = store.cloning; + + let Some(source) = store.announcement.as_ref() else { + return div().into_any_element(); + }; + + let nip05 = ProfileStore::global(cx) + .read(cx) + .get(&source.owner) + .metadata() + .nip05 + .clone() + .filter(|nip05| !nip05.trim().is_empty()); + + let announcement = Rc::new(source.clone()); + let share = Rc::new(ShareTargets::from_announcement(&announcement)); + + let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); + let ngit_command = SharedString::from(format!("git clone {nostr_url}")); + let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); + let git_commands = Rc::new(announcement.clone_urls()); + + let name = repo_display_name(self.store.read(cx)); + let description = announcement.description(); + let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); + + v_flex() + .on_action( + cx.listener(|this, action: &RepoAction, window, cx| match action { + RepoAction::NewIssue => { + open_new_issue_dialog(this.store.clone(), window, cx); + } + RepoAction::NewPR => { + open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); + } + RepoAction::SendPatch => { + open_send_patch_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); + } + RepoAction::About => { + if let Some(announcement) = this.announcement(cx) { + open_about_dialog(announcement.clone(), window, cx); + } + } + RepoAction::Push => this.push_repository(window, cx), + RepoAction::Delete => this.delete_repository(window, cx), + }), + ) + .p_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(description), + ) + .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) + .child( + h_flex() + .mt_2() + .w_full() + .gap_0p5() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("issues") + .action( + BaseButton::new("issues-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new(CustomIconName::GitIssueDone)) + .child("Issues") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(issue_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_issue_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New issue") + }) + }), + ) + .child( + DropdownButton::new("prs") + .action( + BaseButton::new("prs-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new( + CustomIconName::GitPullRequest, + )) + .child("Pull Requests") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(pr_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_pull_request_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New Pull Request") + }) + .menu_element( + Box::new(RepoAction::SendPatch), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::File)) + .child("Send Patch") + }, + ) + }), + ) + .child( + DropdownButton::new("share") + .action( + Button::new("link") + .icon(IconName::Copy) + .tooltip("Copy ID") + .secondary() + .on_click({ + let naddr = share.naddr.clone(); + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(naddr.clone()), + ); + } + }), + ) + .dropdown_menu(move |menu, _, _| share.menu(menu)), + ) + .child( + Button::new("repo-menu-open") + .icon(IconName::EllipsisVertical) + .tooltip("Repository management") + .compact() + .secondary() + .loading(pushing) + .disabled(pushing) + .dropdown_menu(move |menu, _, cx| { + let backend = Backend::global(cx); + let current_user = backend.read(cx).current_user(); + let owner = current_user == Some(announcement.owner); + + let menu = menu.menu_element( + Box::new(RepoAction::About), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Info)) + .child("About") + }, + ); + + if owner { + menu.menu_element(Box::new(RepoAction::Push), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(CustomIconName::Init)) + .child("Republish") + }) + .separator() + .menu_element(Box::new(RepoAction::Delete), |_, cx| { + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Delete)) + .child("Delete") + }) + } else { + menu + } + }), + ) + .child({ + let view = cx.entity(); + let ngit_command = ngit_command.clone(); + let nak_command = nak_command.clone(); + let git_commands = git_commands.clone(); + + Popover::new("clone") + .anchor(Anchor::TopRight) + .trigger( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .loading(cloning) + .disabled(cloning) + .primary(), + ) + .content(move |_, _window, cx| { + let state = cx.entity(); + let ngit_row = copy_row("copy-ngit", &ngit_command, cx); + let nak_row = copy_row("copy-nak", &nak_command, cx); + + v_flex() + .w(px(440.)) + .mt_1() + .p_3() + .gap_4() + .popover_style(cx) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with ngit"), + ) + .child(ngit_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with nak"), + ) + .child(nak_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Grasp Servers"), + ) + .when(!git_commands.is_empty(), |this| { + this.children( + git_commands.iter().enumerate().map( + |(ix, cmd)| { + copy_row( + format!("copy-git-{ix}"), + cmd, + cx, + ) + }, + ), + ) + }) + .when(git_commands.is_empty(), |this| { + this.child( + div() + .text_xs() + .child("No git clone urls."), + ) + }), + ) + .child(div().h_px().w_full().bg(cx.theme().border)) + .child( + h_flex().gap_1().justify_end().child( + Button::new("download") + .icon(CustomIconName::GitClone) + .label("Download") + .primary() + .on_click(move |_event, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + view.update(cx, |this, cx| { + this.clone_to_folder(window, cx); + }); + }), + ), + ) + }) + }), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = repo_display_name(self.store.read(cx)); + let path = self + .store + .read(cx) + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = PixelAvatar::new(path.clone()); + + v_flex() + .px_4() + .pb_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(path), + ), + ) + .child( + Button::new("init") + .icon(CustomIconName::Init) + .label("Initialize on Nostr") + .primary() + .tooltip("Publish this repository to Nostr") + .on_click(cx.listener(|this, _event, window, cx| { + this.open_init_dialog(window, cx); + })), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { + let commits_count = self.history.read(cx).commit_count(); + let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); + + h_flex() + .items_center() + .gap_2() + .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"), + ) + .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"), + ) + .when_some(commits_count, |this, count| { + this.child(CountBadge::new(count)) + }) + .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(); + })), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + Button::new("enc") + .ghost() + .when_some(self.head_commit.as_ref(), |this, commit| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)), + ) + .child( + div() + .max_w(px(200.)) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .child(SharedString::from(&commit.summary)), + ) + }) + .tooltip( + self.head_commit + .as_ref() + .map_or_else(SharedString::default, |commit| { + commit.summary.clone().into() + }), + ) + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(commit) = &this.head_commit { + let id = commit.id.clone(); + this.history.update(cx, |history, cx| { + history.open_commit_diff(&id, window, cx) + }); + } + })), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.refs.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.refs.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::Tag, cx) + }), + ), + ), + ) + .into_any_element() + } + + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| key != &announcement.owner && seen.insert(*key)) + .collect(); + + let owner = profile_store.read(cx).get(&announcement.owner); + let owner_name = owner.name(); + let owner_picture = owner.picture(); + + h_flex() + .w_full() + .gap_3() + .child( + Button::new("maintainers").compact().ghost().child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ), + ) + .into_any_element() + } + + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); + + 'status: for status in statuses { + if self.banners.dismissal(&status) { + continue; + } + for pr in &store.pull_requests { + if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) + { + continue 'status; + } + } + return Some(status); + } + + None + } + + /// The first checkout of this owned repository with unpushed commits. + /// + /// Not dismissed in this panel. + fn push_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); + + 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 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; + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(branch), + ) + .child("has") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _event, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("close-repo") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + let warning = store.last_push_warning.clone()?; + let pushing = store.pushing; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_start() + .justify_between() + .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) + .child( + h_flex() + .gap_2() + .min_w_0() + .flex_1() + .items_start() + .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().warning) + .child(SharedString::from(warning)), + ), + ) + .child( + h_flex() + .gap_1() + .flex_shrink_0() + .child( + Button::new("republish-after-partial-push") + .icon(CustomIconName::Init) + .label("Republish") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(|this, _event, window, cx| { + this.push_repository(window, cx); + })), + ) + .child( + Button::new("dismiss-push-warning") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let branch = status.branch.clone(); + let base = status.base.clone(); + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(branch), + ) + .child("is") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ahead of") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(base), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("create-pr-from-banner") + .icon(IconName::Plus) + .label("Create") + .small() + .info() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_pull_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); + })), + ) + .child( + Button::new("dismiss-ready-banner") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + /// The Files tab body, or the clone/initial-load spinner. fn render_files_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { @@ -201,6 +1822,171 @@ impl RepoDetailView { } } +fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { + let owner = announcement.owner; + let user = nip05 + .map(str::to_owned) + .unwrap_or_else(|| owner.to_bech32().unwrap()); + + let mut url = format!("nostr://{user}"); + if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { + url.push('/'); + url.push_str(hint); + } + url.push('/'); + url.push_str(&announcement.id); + + SharedString::from(url) +} + +fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { + let upstream = announcement.upstream.as_ref()?; + + let (label, clickable) = match &upstream.addr { + Some(addr) => { + // Prefer the upstream's display name when its announcement is known locally. + // Fall back to its repository id otherwise. + let name = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + (SharedString::from(format!("Forked from {name}")), true) + } + None => (SharedString::from(upstream.display().as_str()), false), + }; + + let row = h_flex() + .gap_1() + .items_center() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::GitBranch).small()) + .child(div().whitespace_nowrap().text_ellipsis().child(label)); + + Some(if clickable { + row.id("fork-upstream") + .cursor_pointer() + .hover(|this| this.text_color(cx.theme().foreground)) + .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) + .into_any_element() + } else { + row.into_any_element() + }) +} + +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + /// `https://gitworkshop.dev/` + gitworkshop: String, + /// `https://ditto.pub/` + ditto: String, +} + +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(menu_copy_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(menu_copy_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(menu_copy_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(menu_copy_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } +} + +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} + +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); + 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(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + + Ok(RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) +} + /// The announcement's name or ID for announced repositories, the directory name for local ones. pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { if store.addr().is_none() { diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index c48a52a..0c1bb03 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -1,12 +1,8 @@ -use anyhow::Error; use gpui::prelude::*; -use gpui::{App, Context, Entity, SharedString, Window}; +use gpui::{App, 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>>, @@ -71,7 +67,7 @@ impl RefSwitcher { sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx) } - fn restore_selection( + pub(super) fn restore_selection( &self, select: &Entity>>, previous: &Option, @@ -117,193 +113,3 @@ fn sync_selector( true } - -impl RepoDetailView { - pub(super) fn switch_ref( - &mut self, - kind: RefKind, - name: T, - window: &mut Window, - cx: &mut Context, - ) where - T: Into, - { - if self.refs.switching_ref { - return; - } - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let name = name.into(); - 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.refs - .tag_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - RefKind::Tag => { - self.refs - .branch_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - } - - self.refs.switching_ref = true; - self.ref_generation += 1; - cx.notify(); - - let checkout_name = name.clone(); - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { - match kind { - RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) - } - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(()) => this.reload_worktree(cx), - Err(error) => { - this.error = Some(format!("Failed to check out {name}: {error}").into()); - 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(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - fn reload_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - // Build the tree off the main thread, like [`Self::load_repo`]. - let tree = build_tree_items(&snapshot.entries); - let paths = sorted_worktree_paths(&snapshot.entries); - Ok::<_, Error>((snapshot, tree, paths)) - }) - .await; - - this.update(cx, |this, cx| { - this.refs.switching_ref = false; - - match result { - Ok((snapshot, tree, paths)) => { - this.head_commit = snapshot.head_commit; - let readme_path = snapshot.readme_path; - let readme = snapshot.readme; - this.files.update(cx, |files, cx| { - files.clear_previews(); - files.apply_entries(tree, paths, cx); - files.set_readme(readme_path, readme, cx); - }); - this.history.update(cx, |history, cx| history.reload(cx)); - } - Err(error) => { - this.error = Some(error.to_string().into()); - this.head_commit = None; - // The tree may show files that no longer exist. - this.files.update(cx, |files, cx| { - files.apply_entries(Vec::new(), Vec::new(), cx); - }); - } - } - - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. - pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - let tree = build_tree_items(&snapshot.entries); - let paths = sorted_worktree_paths(&snapshot.entries); - Ok::<_, Error>((snapshot, tree, paths)) - }) - .await; - - this.update(cx, |this, cx| { - match result { - Ok((snapshot, tree, paths)) => { - let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) - != this.head_commit.as_ref().map(|c| &c.id); - - let files_changed = this - .files - .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); - - // A fast-forward of a branch other than the checked-out - // one leaves the worktree untouched. Rebuilding the tree - // and re-parsing the README would flash the panel for - // nothing, so it is a no-op. - if !head_changed && !files_changed { - log::debug!("repo detail catch_up_worktree: no-op"); - return; - } - - this.head_commit = snapshot.head_commit; - - if head_changed { - this.history.update(cx, |history, cx| history.reload(cx)); - } - - cx.notify(); - } - Err(error) => { - this.error = Some(error.to_string().into()); - cx.notify(); - } - } - })?; - - Ok(()) - }); - - self.tasks.push(task); - } -} diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs deleted file mode 100644 index 8308984..0000000 --- a/crates/workspace/src/views/repo/store.rs +++ /dev/null @@ -1,88 +0,0 @@ -use gpui::{Context, Entity, Window}; -use signed_core::Announcement; -use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; - -use super::RepoDetailView; - -impl RepoDetailView { - pub(crate) fn apply_announcement( - &mut self, - announcement: Announcement, - cx: &mut Context, - ) { - let path = self.store.read(cx).path.clone(); - - if let Some(path) = path { - LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); - } - - self.store - .update(cx, |store, cx| store.announce(announcement, cx)); - - // The new address needs its ready-to-contribute statuses requested. - self.refresh_ready_statuses(cx); - cx.notify(); - } - - pub(super) fn attach_store( - &mut self, - store: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._subscriptions - .push(cx.observe_in(store, window, |this, store, window, cx| { - this.refresh_ready_statuses(cx); - if !this.repo_started && store.read(cx).announcement.is_some() { - this.load_repo(window, cx); - } - cx.notify(); - })); - self.refresh_ready_statuses(cx); - } - - fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - - let head = self.store.read(cx).head.clone(); - let (requested, requested_head) = self.banners.ready_requested_at(); - - if requested && requested_head == &head { - return; - } - - self.banners.mark_ready_requested(head.clone()); - - let backend = Backend::global(cx); - let checkout = CheckoutsStore::global(cx); - - let owned = backend - .read(cx) - .current_user() - .is_some_and(|user| self.store.read(cx).is_author(&user)); - - checkout.update(cx, |store, cx| { - // The ready statuses keep the fast poll running while the panel is open. - // The sidebar's push watch alone polls slower. - store.request_statuses(&addr, head, cx); - - if owned { - store.request_push_statuses(&addr, cx); - } - }); - } - - pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return false; - }; - - let checkouts = CheckoutsStore::global(cx).read(cx); - let ready_statuses = checkouts.ready_statuses_of(&addr); - let push_statuses = checkouts.push_statuses_of(&addr); - - self.banners.set_statuses(ready_statuses, push_statuses) - } -}