diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 2bd907b..854a2a1 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -397,6 +397,22 @@ impl RepoStore { }; let again = this.update(cx, |this, cx| { + // Compare before moving the freshly queried data in, so a pass + // that found nothing new does not notify observers. The store + // is polled in bursts while a sync is in flight; notifying on + // every identical pass would re-render the repository panel + // several times for no visible change. + let head_changed = state + .as_ref() + .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + let changed = this.announcement != announcement + || head_changed + || this.issues != issues + || this.patches != patches + || this.pull_requests != pull_requests + || this.comments != comments + || this.status_by_root != status_by_root; + this.announcement = announcement; // The announcement may list relays for this repository's activity. @@ -454,7 +470,9 @@ impl RepoStore { }); } - cx.notify(); + if changed { + cx.notify(); + } this.refresh.finish() })?; diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index b4a88f7..4189909 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -154,7 +154,6 @@ pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, /// Latest known activity timestamp per repository. - /// Covers announcements, state updates, patches, PRs, issues and statuses. pub last_activity: Arc>, /// Issues, pull requests and commits per repository. /// @@ -178,6 +177,7 @@ impl RepoListStore { /// Create the store listing all announcements. pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); + let weak = cx.entity().downgrade(); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { @@ -217,17 +217,12 @@ impl RepoListStore { } }); - let weak = cx.entity().downgrade(); cx.defer(move |cx| { - let result = weak.update(cx, |this, cx| { + weak.update(cx, |this, cx| { this.subscribe_remote(cx); - // Query the local database right away. - // The list never waits for the relay syncs started above to finish. this.refresh_initial(cx); - }); - if let Err(error) = result { - log::warn!("repo list store dropped before bootstrap could run: {error}"); - } + }) + .ok(); }); Self { diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/commit_diff/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/diff.rs rename to crates/workspace/src/views/commit_diff/mod.rs index bcd6474..e6a3904 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -21,7 +21,7 @@ use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; -use super::helpers::{ +use crate::views::repo::helpers::{ DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, }; diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/issues/detail.rs similarity index 98% rename from crates/workspace/src/views/repo_detail/issue_detail.rs rename to crates/workspace/src/views/issues/detail.rs index 08b23e2..d6911f4 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -13,7 +13,7 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; /// Detail panel of a single issue. pub struct IssueDetailView { diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/issues/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/issues.rs rename to crates/workspace/src/views/issues/mod.rs index 6820606..6aceaec 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -21,7 +21,9 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::issue_detail::IssueDetailView; +pub(super) mod detail; + +use self::detail::IssueDetailView; /// Height of one issue row in the virtual list. const ISSUE_ROW_HEIGHT: f32 = 73.; diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index eb79c7e..a098cb4 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,11 +1,15 @@ +mod commit_diff; mod dialog_state; mod inbox; -mod repo_detail; +mod issues; +mod pull_requests; +mod repo; mod repo_list; +mod send_patch; pub(crate) mod sidebar; pub use inbox::InboxView; -pub use repo_detail::RepoDetailView; -pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel}; +pub use repo::RepoDetailView; +pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/pull_requests/detail.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/pull_request_detail.rs rename to crates/workspace/src/views/pull_requests/detail.rs index 86ed7ba..8439a3b 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -30,8 +30,8 @@ use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::views::commit_diff::{CommitDiffView, DiffPane}; +use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; /// Height of one commit row in the commits tab's virtual list. const ROW_HEIGHT: f32 = 37.; diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/pull_requests/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/pull_requests.rs rename to crates/workspace/src/views/pull_requests/mod.rs index f5cab0e..e32bdda 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -19,10 +19,13 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::RepoAction; -use super::new_pull_request::open_new_pull_panel; -use super::pull_request_detail::PullRequestDetailView; +pub(super) mod detail; +pub(super) mod new; + +use self::detail::PullRequestDetailView; +use self::new::open_new_pull_panel; use super::send_patch::open_send_patch_panel; +use crate::views::repo::RepoAction; /// Height of one pull request row in the virtual list. const ROW_HEIGHT: f32 = 73.; diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/pull_requests/new.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/new_pull_request.rs rename to crates/workspace/src/views/pull_requests/new.rs index b2b4a07..f087c4a 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -29,9 +29,8 @@ use signed_git::{ use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder}; -use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::ref_selector_trigger; +use crate::views::commit_diff::{CommitDiffView, DiffPane}; +use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger}; /// The new pull request panel of a repository. pub struct NewPullRequestView { @@ -1342,7 +1341,7 @@ impl NewPullRequestView { } /// Open the new pull request panel in the center dock. -pub(super) fn open_new_pull_panel( +pub(crate) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, window: &mut Window, diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo/about.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/about.rs rename to crates/workspace/src/views/repo/about.rs diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs new file mode 100644 index 0000000..095fec5 --- /dev/null +++ b/crates/workspace/src/views/repo/actions.rs @@ -0,0 +1,265 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; +use dock::{DockArea, add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{App, Context, Entity, WeakEntity, Window}; +use gpui_base::dock::PanelView; +use nostr::prelude::EventId; +use signed_core::{Announcement, filters}; +use signed_state::{Backend, RepoListStore, 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 { + /// Re-push the repository's refs to its announced grasp servers. + pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + + self.error = None; + cx.notify(); + + store + .update(cx, |store, cx| store.push_repository(cx)) + .detach(); + } + + /// Push the unpushed commits of the local checkout at `path`. + pub(super) fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = self.store.clone() else { + return; + }; + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + // The store owns the push, its busy flag and error reporting. + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + // The remote moved, refresh the mirror browsing. + // Failures already surfaced in the store's error banner. + if let Ok(()) = push.await { + this.update_in(cx, |this, window, cx| { + this.load_repo(window, cx); + })?; + } + + Ok(()) + }); + + task.detach(); + } + + /// Delete the repository from nostr, announcement, state and activity. + pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + store + .update(cx, |store, cx| store.delete_repository(cx)) + .detach(); + } + + /// Open the issues list panel in the dock area. + pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + 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); + }); + } + + /// Open the pull requests list panel in the dock area. + pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + 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 upstream announcement may not be in the local database yet. + /// Subscribe for it and open the panel as soon as it lands. + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + if self.pending_upstream.is_some() { + return; + } + + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + + let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { + return; + }; + + if let Some(found) = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == addr) + .cloned() + { + open_repo_panel(&self.dock_area, &found, window, &mut *cx); + return; + } + + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { + backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); + }); + self.pending_upstream = Some(addr); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + for _ in 0..60 { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + + let opened = this.update_in(cx, |this, window, cx| { + let Some(addr) = this.pending_upstream.clone() else { + return true; + }; + let found = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == addr) + .cloned(); + match found { + Some(found) => { + this.pending_upstream = None; + open_repo_panel(&this.dock_area, &found, window, &mut *cx); + true + } + None => false, + } + })?; + + if opened { + return Ok(()); + } + } + + this.update(cx, |this, _cx| this.pending_upstream = None)?; + Ok(()) + }); + + task.detach(); + } + + /// Open the dialog guiding the user through publishing 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.local_path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } +} + +/// Open `announcement` as a repository panel in the dock's center. +pub(crate) fn open_repo_panel( + dock_area: &WeakEntity, + announcement: &Announcement, + window: &mut Window, + cx: &mut App, +) -> Entity { + let detail = + cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); + + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); + }); + } + + detail +} + +/// The nostr store of `announcement`'s repository, without opening a repository panel. +fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +} + +/// An item of a repository to open from outside its detail panel. +/// A patch has no detail view in Signed, so it opens nothing. +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. +/// +/// The repository store is built here, not taken from a `RepoDetailView`, so the +/// item panel is the only panel docked. +/// +/// A patch opens nothing: patches are only consumed inside a pull request's +/// detail panel, and have no panel of their own. +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + announcement: &Announcement, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let panel: Arc = + match item { + RepoItem::Issue(issue_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| { + PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) + })) + } + RepoItem::Patch => return, + }; + + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel, window, cx); + }); +} diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs new file mode 100644 index 0000000..c50a7c2 --- /dev/null +++ b/crates/workspace/src/views/repo/banners.rs @@ -0,0 +1,327 @@ +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; + +impl RepoDetailView { + /// The first checkout ready for a pull request on this repository. + /// Not covered by an open PR of the signed-in user. + /// Not dismissed in this panel. + /// The repository's own checkouts are not suggested here. + /// Their work is pushed, see [`Self::push_suggestion`]. + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.as_ref()?; + let addr = store.read(cx).addr().clone(); + let user = Backend::global(cx).read(cx).current_user()?; + if store.read(cx).is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); + + 'status: for status in statuses { + if self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + { + continue; + } + let store = store.read(cx); + 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 entity = self.store.as_ref()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !entity.read(cx).is_author(&user) { + return None; + } + + let addr = entity.read(cx).addr().clone(); + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); + + statuses.into_iter().find(|status| { + !self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + }) + } + + /// The ready-to-push banner of an owned repository. + /// + /// A local checkout has unpushed commits, with a Push action and a dismiss control. + pub(super) fn render_push_banner(&self, cx: &Context) -> Option { + let status = self.push_suggestion(cx)?; + let key = (status.path.clone(), status.branch.clone()); + let path = status.path.clone(); + // The push busy flag lives on the store; it disables the banner's triggers. + let pushing = self + .store + .as_ref() + .is_some_and(|store| 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(status.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.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// Warning after a push that only some grasp servers accepted. + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.as_ref()?; + let store = 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| { + if let Some(store) = this.store.clone() { + store.update(cx, |store, _| { + store.last_push_warning = None; + }); + } + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// The ready-to-contribute banner of the repository panel. + pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let key = (status.path.clone(), status.branch.clone()); + + let 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(status.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(status.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| { + if let Some(store) = this.store.clone() { + open_new_pull_panel( + this.dock_area.clone(), + store, + 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.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } +} diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo/files.rs similarity index 53% rename from crates/workspace/src/views/repo_detail/browser.rs rename to crates/workspace/src/views/repo/files.rs index 389aeae..152c317 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -1,3 +1,6 @@ +use std::path::{Component, Path}; + +use anyhow::Error; use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; @@ -10,7 +13,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; -use super::helpers::{code_language, is_markdown_path}; +use crate::views::repo::helpers::{code_language, is_markdown_path}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -39,6 +42,8 @@ pub(super) struct MarkdownView { /// Source path, `None` means the repository README. pub(super) path: Option, pub(super) state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, } /// A code file loaded into a persistent [`InputState`]. @@ -46,6 +51,21 @@ pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, pub(super) state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, +} + +/// Hash of a preview's source text. +/// +/// Two loads of the same document produce the same hash, so the persistent +/// markdown/editor state can be kept instead of rebuilt, which would re-parse +/// and flash the pane. +fn source_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() } /// Spinner shown while a document is being loaded/parsed. @@ -217,9 +237,21 @@ impl RepoDetailView { text: &str, cx: &mut Context, ) { + let hash = source_hash(text); + if let Some(md) = &self.md + && md.path == path + && md.source_hash == hash + { + return; + } + let state = cx.new(|cx| TextViewState::markdown("", cx)); state.update(cx, |state, cx| state.push_str(text, cx)); - self.md = Some(MarkdownView { path, state }); + self.md = Some(MarkdownView { + path, + state, + source_hash: hash, + }); } /// The persistent markdown TextView for `path`, where `None` is the README. @@ -257,6 +289,14 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) { + let hash = source_hash(text); + if let Some(code) = &self.code + && code.path == path + && code.source_hash == hash + { + return; + } + let language = code_language(path.as_ref()).unwrap_or("text"); let state = cx.new(|cx| { EditorState::new(window, cx) @@ -265,7 +305,11 @@ impl RepoDetailView { .line_number(true) .folding(true) }); - self.code = Some(CodeView { path, state }); + self.code = Some(CodeView { + path, + state, + source_hash: hash, + }); } /// The persistent code editor for `path`, or a spinner while the file loads or parses. @@ -286,3 +330,176 @@ impl RepoDetailView { .into_any_element() } } + +impl RepoDetailView { + /// Preview the file at `path`, relative to the worktree root. + fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { + self.selected_file = Some(path.into()); + + if self.files.contains_key(path) { + // The file is cached, but the markdown or code state may hold a different file. + // Re-point it at this one, the parse runs on a background task either way. + // Without this, the pane would show a spinner forever. + if let Some(FileContent::Text(text)) = self.files.get(path) { + let text = text.clone(); + if is_markdown_path(path) { + if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { + self.set_markdown(Some(path.into()), &text, cx); + } + } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { + self.set_code(path.into(), &text, window, cx); + } + } + cx.notify(); + return; + } + if self.loading_files.contains(path) { + cx.notify(); + return; + } + + // Paths come from our own tree walk, but never trust them. + // Refuse anything that could escape the worktree. + let rel = Path::new(path); + let unsafe_path = rel.is_absolute() + || rel.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + if unsafe_path { + return; + } + + self.loading_files.insert(path.to_string()); + let path = path.to_string(); + + self.load_commit(&path, cx); + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let path_for_read = path.clone(); + let content = cx + .background_spawn(async move { + let full = worktree.join(&path_for_read); + // Refuse oversized files before reading them. + // Reading a multi-gigabyte file just to classify it is wasteful. + // It would burn disk and memory bandwidth. + let metadata = match std::fs::metadata(&full) { + Ok(metadata) => metadata, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + if metadata.len() > MAX_PREVIEW_BYTES as u64 { + return Ok(FileContent::TooLarge); + } + let bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + match String::from_utf8(bytes) { + Ok(text) => Ok(FileContent::Text(text)), + Err(_) => Ok(FileContent::Binary), + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + // The worktree was switched while this file was reading. + // The result belongs to the previous branch. + // Clear the in-flight marker either way. + // Otherwise the path could never be loaded again. + if generation != this.ref_generation { + this.loading_files.remove(&path); + return; + } + this.loading_files.remove(&path); + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind { + if is_markdown_path(&path) { + let same = this.md.as_ref().map(|md| md.path.as_deref()) + == Some(Some(path.as_str())); + if !same { + this.set_markdown(Some(path.clone().into()), text, cx); + } + } else { + let same = this.code.as_ref().map(|code| code.path.as_str()) + == Some(path.as_str()); + if !same { + this.set_code(path.clone().into(), text, window, cx); + } + } + this.preview_bytes += text.len(); + } + this.files.insert(path.clone(), kind); + this.file_order.push_back(path); + this.evict_previews(); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Drop the cached preview, editor and commit state of `path`. + pub(super) fn drop_preview_of(&mut self, path: &str) { + if let Some(FileContent::Text(text)) = self.files.remove(path) { + self.preview_bytes -= text.len(); + } + self.commits.remove(path); + if self.selected_file.as_deref() == Some(path) { + self.selected_file = None; + } + if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { + self.md = None; + } + if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { + self.code = None; + } + } + + /// Drop the oldest previews beyond the cache caps. + /// Keep the currently selected file. + /// An evicted file's parsed editor state drops with its entry. + /// Re-opening it re-parses on a background task. + fn evict_previews(&mut self) { + while (self.files.len() > MAX_PREVIEWED_FILES + || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) + && self.file_order.len() > 1 + { + let path = self.file_order.pop_front().expect("non-empty"); + if Some(path.as_str()) == self.selected_file.as_deref() { + self.file_order.push_back(path); + continue; + } + if let Some(FileContent::Text(text)) = self.files.remove(&path) { + self.preview_bytes -= text.len(); + } + if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { + self.md = None; + } + if self + .code + .as_ref() + .is_some_and(|code| code.path.as_ref() == path.as_str()) + { + self.code = None; + } + self.commits.remove(&path); + } + } +} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs new file mode 100644 index 0000000..b652dd5 --- /dev/null +++ b/crates/workspace/src/views/repo/header.rs @@ -0,0 +1,728 @@ +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; +use gpui_component::{ + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, +}; +use nostr::prelude::{RelayUrl, ToBech32}; +use signed_core::Announcement; +use signed_state::{Backend, ProfileStore, RepoListStore}; +use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; + +use super::{RepoAction, RepoDetailView}; +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::repo::helpers::{ShareTargets, ref_selector_trigger}; +use crate::views::send_patch::open_send_patch_panel; + +impl RepoDetailView { + /// The NIP-34 header, actions and issues/PR counts. + /// Or the local header with an Init button for an unpublished repository. + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.local_path.is_some() { + return self.render_local_header(cx); + } + + let Some(store_entity) = self.store.as_ref() else { + return div().into_any_element(); + }; + + let store = store_entity.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().or(self.initial.as_ref()) else { + return div().into_any_element(); + }; + + // Derived NIP-34 header data, share targets and clone commands. + // Rebuilt per frame: two bech32 encodes and a couple of format strings. + 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 = self.display_name(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 => { + if let Some(store) = this.store.clone() { + open_new_issue_dialog(store, window, cx); + } + } + RepoAction::NewPR => { + if let Some(store) = this.store.clone() { + open_new_pull_panel(this.dock_area.clone(), store, window, cx); + } + } + RepoAction::SendPatch => { + if let Some(store) = this.store.clone() { + open_send_patch_panel(this.dock_area.clone(), store, window, cx); + } + } + RepoAction::About => { + 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() + } + + /// Header for a local, not yet published, repository. + /// The directory name and path with an Init button instead of the NIP-34 actions. + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = self.display_name(cx); + let path = self + .local_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() + } + + /// The tab row shared by both header variants. + /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. + fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { + let commits_count = self.all_commits.as_ref().map(|list| list.total); + let worktree_empty = self.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.open_commit_diff(&id, window, cx); + } + })), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.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.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() + } +} + +/// The `nostr://...` clone URL of an announcement, NIP-34. +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) +} + +/// The forked-from row of the detail header. +/// +/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. +/// Plain text when it only carries a git 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() + }) +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo/helpers.rs similarity index 85% rename from crates/workspace/src/views/repo_detail/helpers.rs rename to crates/workspace/src/views/repo/helpers.rs index fd7be3c..bf95cd3 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo/helpers.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{Caret, ComboboxTriggerContext}; use gpui_component::input::{Textarea, TextareaState}; @@ -15,12 +15,12 @@ use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; use nostr::prelude::{Event, EventId, PublicKey}; use signed_core::Announcement; -use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; +use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; -use utils::relative_time; +use utils::{relative_time, relative_time_secs}; -pub(super) struct TreeItemSeed { +pub(crate) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, /// File or directory name. @@ -28,7 +28,7 @@ pub(super) struct TreeItemSeed { children: Vec, } -pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { +pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { let mut item = TreeItem::new(seed.id, seed.label); if expand_folders && !seed.children.is_empty() { @@ -49,7 +49,7 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< } /// Build nested tree items from a flat entry list sorted dirs-first. -pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { +pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { // Node indices by full path, so parents resolve in constant time while inserting. let mut index: HashMap = HashMap::new(); let mut nodes: Vec<(String, String, Vec)> = Vec::new(); @@ -93,8 +93,21 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { roots.iter().map(|root| assemble(*root, &nodes)).collect() } +/// Sorted relative paths of a worktree snapshot. +/// +/// Compared against the `worktree_paths` of a repository panel to skip +/// rebuilding the explorer when a refresh left the worktree unchanged. +pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { + let mut paths: Vec = entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + paths.sort(); + paths +} + /// The markdown fence language for a file path, or `None` for plain text. -pub(super) fn code_language(path: &str) -> Option<&'static str> { +pub(crate) fn code_language(path: &str) -> Option<&'static str> { let name = Path::new(path) .file_name() .and_then(|name| name.to_str()) @@ -145,7 +158,7 @@ pub(super) fn code_language(path: &str) -> Option<&'static str> { } /// Whether a file path has a markdown extension. -pub(super) fn is_markdown_path(path: &str) -> bool { +pub(crate) fn is_markdown_path(path: &str) -> bool { Path::new(path) .extension() .and_then(|ext| ext.to_str()) @@ -157,21 +170,21 @@ pub(super) fn is_markdown_path(path: &str) -> bool { }) } -pub(super) struct ShareTargets { +pub(crate) struct ShareTargets { /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(super) naddr: String, + pub(crate) naddr: String, /// Hex ID of the announcement event itself. - pub(super) event_id: String, + pub(crate) event_id: String, /// NIP-34 coordinate `30617::`. - pub(super) coordinate: String, + pub(crate) coordinate: String, /// `https://gitworkshop.dev/` - pub(super) gitworkshop: String, + pub(crate) gitworkshop: String, /// `https://ditto.pub/` - pub(super) ditto: String, + pub(crate) ditto: String, } impl ShareTargets { - pub(super) fn from_announcement(announcement: &Announcement) -> Self { + pub(crate) fn from_announcement(announcement: &Announcement) -> Self { let addr = announcement.addr(); let coordinate = addr.to_string(); let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) @@ -190,7 +203,7 @@ impl ShareTargets { /// The share dropdown menu, one row per target. /// /// Each shows a compact label, the copy button and row click copy the full value. - pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { + pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu { menu.min_w(px(340.)) .item(menu_copy_row( "copy-gitworkshop", @@ -231,15 +244,15 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { } /// Width of one line-number gutter in a diff row. -pub(super) const GUTTER_WIDTH: f32 = 44.; +pub(crate) const GUTTER_WIDTH: f32 = 44.; /// Height of one row in a virtual diff list. -pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; +pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.; /// One row of a virtual diff list, a hunk header or a line of a hunk. /// /// Shared by the commit diff and pull request diff viewers. #[derive(Clone, Copy)] -pub(super) enum DiffRow { +pub(crate) enum DiffRow { Hunk { old_start: u32, old_lines: u32, @@ -251,7 +264,7 @@ pub(super) enum DiffRow { } /// The rows of `file`'s diff, one header row per hunk then its lines. -pub(super) fn diff_rows(file: &FileDiff) -> Vec { +pub(crate) fn diff_rows(file: &FileDiff) -> Vec { let mut rows = Vec::new(); for (hunk_ix, hunk) in file.hunks.iter().enumerate() { rows.push(DiffRow::Hunk { @@ -269,7 +282,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec { } /// One row of the virtual diff list, a hunk header or a single line. -pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { +pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { match row { DiffRow::Hunk { old_start, @@ -298,7 +311,7 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any /// One diff line, old and new line numbers in the gutters. /// /// The content is tinted by kind, addition, deletion or context. -pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { +pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { let bg = match line.kind { DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), @@ -346,7 +359,7 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { } /// Find a tree item by id, searching into nested children. -pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { +pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { let id = id?; items.iter().find_map(|item| { if item.id.as_ref() == id { @@ -358,12 +371,12 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<& } /// The root issue events of a repo store, for the shared detail sections. -pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { &store.issues } /// The root pull request events of a repo store, for the shared detail sections. -pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { &store.pull_requests } @@ -372,7 +385,7 @@ pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { /// The kind icon, the selection or placeholder, and the caret. /// `Combobox` replaces its default trigger entirely, /// the only way to show an icon inside it. -pub(super) fn ref_selector_trigger( +pub(crate) fn ref_selector_trigger( ctx: &ComboboxTriggerContext>, icon: CustomIconName, cx: &App, @@ -406,7 +419,7 @@ pub(super) fn ref_selector_trigger( } /// Section heading of a detail sidebar, shared by the issue and PR panels. -pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { +pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { div() .text_xs() .font_semibold() @@ -416,7 +429,7 @@ pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { } /// Right sidebar with participants and labels of a root event, issue or PR. -pub(super) fn sidebar_section( +pub(crate) fn sidebar_section( store: &Entity, id: EventId, roots: fn(&RepoStore) -> &[Event], @@ -499,7 +512,7 @@ pub(super) fn sidebar_section( } /// The comments on a root event, issue or PR, one card per comment. -pub(super) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { +pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { let store = store.read(cx); let comments: Vec<&Event> = store.comments_of(&root).collect(); let title = SharedString::from(format!("Discussions {}", comments.len())); @@ -549,7 +562,7 @@ pub(super) fn comments_section(store: &Entity, root: EventId, cx: &Ap /// The comment form posting to an issue or PR root event. /// /// `roots` selects the root's list within the store, issues or pull requests. -pub(super) fn comment_form( +pub(crate) fn comment_form( store: &Entity, root: EventId, roots: fn(&RepoStore) -> &[Event], @@ -608,6 +621,68 @@ pub(super) fn comment_form( .into_any_element() } +/// Height of one commit row in a commit virtual list. +pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; + +/// One commit row of a virtual list, shared by the commits tab and the +/// new-pull-request commit picker. +pub(crate) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_event, window, cx| on_click(window, cx)) + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs new file mode 100644 index 0000000..9438c0f --- /dev/null +++ b/crates/workspace/src/views/repo/history.rs @@ -0,0 +1,237 @@ +use std::path::PathBuf; +use std::rc::Rc; + +use anyhow::Error; +use dock::{add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{AnyElement, Context, Window, div, px, size}; +use gpui_component::scroll::Scrollbar; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list}; +use signed_ui::placeholder; + +use super::RepoDetailView; +use crate::views::commit_diff::CommitDiffView; +use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row}; + +impl RepoDetailView { + pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { + let Some(list) = self.all_commits.as_ref() else { + return if self.loading_all_commits { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + } else { + placeholder("Failed to load commits", cx) + }; + }; + + if list.commits.is_empty() { + return placeholder("No commits found", cx); + } + + // Copy only the values the element tree needs. + // The list is borrowed by the renderer below instead of cloned per frame. + // A full history can be tens of thousands of commits. + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let shown = list.commits.len(); + let total = list.total; + + v_flex() + .relative() + .flex_1() + .w_full() + .min_h_0() + .child( + v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { + let view = cx.entity().downgrade(); + let commits = this + .all_commits + .as_ref() + .map(|list| list.commits.as_slice()) + .unwrap_or(&[]); + + range + .map(|ix| { + let id = commits[ix].id.clone(); + let view = view.clone(); + + commit_row( + ix, + &commits[ix], + move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.open_commit_diff(&id, window, cx) + }); + } + }, + cx, + ) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .when(shown < total, |this| { + // The history is capped. + // Tell the user the list is truncated. + this.child( + div() + .py_2() + .w_full() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Showing {shown} of {total} commits")), + ) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) + .into_any_element() + } +} + +impl RepoDetailView { + /// Queue `path` for the per-file commit query. + /// Requests are batched into one history walk, see [`Self::load_commits`]. + pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { + return; + } + self.pending_commits.push(path.to_string()); + if !self.loading_commits { + self.load_commits(cx); + } + } + + /// Walk history once for every queued path on a background task. + /// Cache the latest commit touching each path in [`Self::commits`]. + /// That feeds the file header in the content column. + /// Batching shares one walk across paths queued while the previous walk ran. + fn load_commits(&mut self, cx: &mut Context) { + if self.pending_commits.is_empty() || self.loading_commits { + return; + } + let Some(worktree) = self.worktree.clone() else { + self.pending_commits.clear(); + return; + }; + + self.loading_commits = true; + let paths = std::mem::take(&mut self.pending_commits); + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let rels: Vec = paths.iter().map(PathBuf::from).collect(); + let result = cx + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) + .await; + + this.update(cx, |this, cx| { + this.loading_commits = false; + if generation == this.ref_generation + && let Ok(found) = result + { + for (path, commit) in found { + this.commits + .insert(path.to_string_lossy().into_owned(), commit); + } + } + // Paths queued while the walk was in flight start the next batch. + // A stale walk, branch switched mid-flight, must not strand them. + // This runs under the current generation regardless of the result. + if !this.pending_commits.is_empty() { + this.load_commits(cx); + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Walk all commits reachable from HEAD on a background task. + /// For the Commits tab and its total-count badge. + /// [`CommitList`] caps the list, only the newest commits are materialized. + pub(super) fn load_all_commits(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + // A stale walk, branch switched mid-flight, must not leave the flag set. + // Otherwise the Commits tab would spin forever. + if generation != this.ref_generation { + this.loading_all_commits = false; + return; + } + if let Ok(list) = result { + let count = list.commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(list); + } + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Open a new panel showing the diff of `commit_id`. + pub(super) fn open_commit_diff( + &mut self, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + // Same display name as the repo detail panel's title. + let repo_name = self.display_name(cx); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } +} diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/init_dialog.rs rename to crates/workspace/src/views/repo/init_dialog.rs diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs new file mode 100644 index 0000000..bf6a761 --- /dev/null +++ b/crates/workspace/src/views/repo/loading.rs @@ -0,0 +1,429 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Error; +use gix::Repository; +use gpui::prelude::*; +use gpui::{Context, Entity, PathPromptOptions, SharedString, Window}; +use gpui_component::combobox::ComboboxState; +use gpui_component::searchable_list::SearchableVec; +use nostr::prelude::Url; +use signed_git::FileCommit; +use signed_state::GitStore; + +use super::RepoDetailView; +use crate::views::repo::helpers::{ + TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items, +}; + +/// Everything loaded from the local clone for the explorer. +struct RepoData { + tree: Vec, + /// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`]. + 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. + /// + /// A local, not yet published, repository opens straight from disk. + /// An announced repository's clone, if any, loads first without touching the network. + pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + // Local repositories live on disk at their scan path. + // No clone step or network refresh applies here. + if let Some(local_path) = self.local_path.clone() { + 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(()) + }); + + task.detach(); + + return; + } + + let Some(initial) = self.initial.as_ref() else { + return; + }; + + let cache = GitStore::global(cx).cache().clone(); + let addr = initial.addr(); + let clone_urls: Vec = initial.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(_))); + + // No local clone yet, so clone from the network then load. + 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 = Self::sync_ref_selector( + &this.branch_select, + &mut this.ref_branches, + branches, + current_branch.map(Into::into), + window, + cx, + ); + let tags_changed = Self::sync_ref_selector( + &this.tag_select, + &mut this.ref_tags, + tags, + None, + window, + cx, + ); + + let 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; + + log::debug!( + "repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}" + ); + + // Only a moved HEAD invalidates the commit list. + // Leaving an in-flight walk alone when HEAD did not move + // keeps a refresh that learned nothing new from flashing + // the commits tab. + if head_changed { + this.all_commits = None; + this.loading_all_commits = false; + this.load_all_commits(cx); + } + + // A fast-forward may touch a branch that is not checked out. + // `catch_up_worktree` no-ops when the tree is unchanged and + // re-renders only when it actually rebuilt something. + if moved { + this.catch_up_worktree(cx); + } + + if branches_changed || tags_changed || head_changed { + cx.notify(); + } + } + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Apply the loaded repository data. + 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); + self.head_commit = head_commit; + self.worktree_paths = sorted_worktree_paths(&entries); + self.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Populate the branch/tag selectors with the local refs. + // Select the branch HEAD points to. + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + + Self::sync_ref_selector( + &self.branch_select, + &mut self.ref_branches, + branches, + current_branch.map(Into::into), + window, + cx, + ); + Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx); + + self.load_all_commits(cx); + + if let Some((path, bytes)) = readme_path.zip(readme) { + self.readme_name = Some(path.to_string_lossy().into()); + self.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + self.set_markdown(None, &text, cx); + } + } + } + + /// Point a ref selector at `items`, selecting `selected` when given. + /// + /// Updates the items and selection only when they differ from `cached` and + /// the current selection. `set_items`/`set_selected_values` notify the + /// combobox, which re-renders the header, so skipping the no-op keeps a + /// background refresh that learned nothing new from flashing the selectors. + /// Returns whether anything was set. + fn sync_ref_selector( + select: &Entity>>, + cached: &mut Vec, + items: Vec, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let items_changed = *cached != items; + let selection_changed = selected + .as_ref() + .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); + + if !items_changed && !selection_changed { + return false; + } + + select.update(cx, |state, cx| { + if items_changed { + state.set_items(SearchableVec::from(items.clone()), window, cx); + } + if let Some(value) = selected + && (items_changed || selection_changed) + { + state.set_selected_values(std::slice::from_ref(&value), window, cx); + } + }); + *cached = items; + + true + } + + /// Clone the repository into a user-chosen folder outside the cache. + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + + 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. + // Both are sanitized to a safe single path component. + 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(()) + }); + + task.detach(); + } +} + +/// Read the worktree state of `repo`, no network. +/// +/// Entries, README, refs and HEAD commit. +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + // Ref listing is auxiliary UI. + // A broken ref must not prevent the explorer from loading. + // Failures degrade to empty selectors. + let (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), + }; + let head_commit = signed_git::head_commit(repo).unwrap_or(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 new file mode 100644 index 0000000..5607fce --- /dev/null +++ b/crates/workspace/src/views/repo/mod.rs @@ -0,0 +1,433 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::rc::Rc; + +use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use gpui::prelude::*; +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, WeakEntity, Window, +}; +use gpui_component::alert::Alert; +use gpui_component::combobox::{ComboboxEvent, ComboboxState}; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::tree::TreeState; +use gpui_component::{VirtualListScrollHandle, h_flex, v_flex}; +use signed_core::{Announcement, RepoAddr}; +use signed_git::{CommitList, FileCommit}; +use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore}; + +mod about; +mod actions; +mod banners; +mod files; +mod header; +pub(super) mod helpers; +mod history; +mod init_dialog; +mod loading; +mod refs; +mod store; + +pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; + +use self::files::{CodeView, FileContent, MarkdownView}; + +/// What kind of ref the header selectors switch to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RefKind { + /// A local branch `refs/heads/*`, HEAD stays attached. + Branch, + /// A tag `refs/tags/*`, HEAD becomes detached. + Tag, +} + +/// Header actions dispatched by the dropdown menus of the header buttons. +/// `pub(crate)` because the pull-request list panel shares this action set. +/// It offers the New-PR and Send-patch actions in its own dropdown. +#[derive(Clone, Action, PartialEq, Eq)] +#[action(namespace = repo, no_json)] +pub(crate) enum RepoAction { + /// Open the new issue dialog. + NewIssue, + /// Open the new pull request dialog. + NewPR, + /// Open the send patch panel. + SendPatch, + /// Open the about dialog. + About, + /// Re-push the repository to its grasp servers. + Push, + /// Delete the repository from nostr, owner only. + Delete, +} + +/// Detail view of a repository, header, stats and metadata. +/// +/// A file explorer with README preview, cloned from the announcement's `clone` URLs. +pub struct RepoDetailView { + focus_handle: FocusHandle, + /// Dock area the detail view lives in. + /// + /// New panels, commit diffs, are added there. + dock_area: WeakEntity, + /// Snapshot taken at open time. + /// + /// `None` for local repositories that haven't been published yet. + initial: Option, + /// Per-repository nostr store, holding announcement, issues, PRs and statuses. + /// + /// `None` until a local repository is initialized to NIP-34. + store: Option>, + /// Path of the local repository when opened from the scan. + /// + /// `None` once it is initialized to NIP-34, or for announced repositories. + local_path: Option, + /// File explorer state, the worktree of the local clone. + tree_state: Entity, + /// Root of the local clone, for reading files on demand. + worktree: Option, + /// Sorted relative paths of the tree currently shown. + /// + /// A background refresh that did not change the tree skips rebuilding it, + /// see [`Self::catch_up_worktree`], so a fetch that learned nothing new + /// does not flash the explorer. + worktree_paths: Vec, + /// Markdown document currently in the preview pane, README or a file. + md: Option, + /// Code file currently in the preview pane. + code: Option, + readme_name: Option, + /// Currently previewed file, a relative path, and its contents. + selected_file: Option, + files: HashMap, + /// Paths of cached previews, oldest first. + /// Feeds the eviction caps in [`Self::evict_previews`]. + file_order: VecDeque, + /// Total text bytes held by [`Self::files`]. + preview_bytes: usize, + /// Reads in flight, to avoid duplicate loads. + loading_files: HashSet, + /// Latest commit touching a previewed file or the README, keyed by path. + commits: HashMap, + /// Paths queued for the next batched commit query, see [`Self::load_commits`]. + pending_commits: Vec, + /// A batched commit query is in flight. + loading_commits: bool, + /// Active header tab, 0 = Files tree, 1 = Commits. + active_tab: usize, + /// Commits reachable from HEAD, newest first. + /// `None` until the walk finishes or fails. + /// [`CommitList`] caps the list, `total` feeds the tab badge. + all_commits: Option, + /// Commit walk in flight. + loading_all_commits: bool, + /// Virtual list state of the Commits tab. + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + /// Commit HEAD currently points to, shown in the header button. + head_commit: Option, + /// Branch selector in the header, local branches, searchable. + branch_select: Entity>>, + /// Tag selector in the header, tags, searchable. + tag_select: Entity>>, + /// Branch names currently in `branch_select`, for cheap no-op detection. + ref_branches: Vec, + /// Tag names currently in `tag_select`, for cheap no-op detection. + ref_tags: Vec, + /// A branch/tag switch is in flight, checkout plus explorer reload. + switching_ref: bool, + /// Bumped on every branch/tag switch. + /// In-flight loads with an older generation are discarded when they complete. + ref_generation: u64, + /// Subscriptions keeping the selectors' confirm events alive. + _subscriptions: Vec, + /// `(path, branch)` ready-suggestions dismissed by the user, per panel. + banner_dismissed: HashSet<(PathBuf, String)>, + /// The announced HEAD the ready statuses were last requested with. + /// Whether they were requested at all. + /// Re-requested only when the HEAD, the base default, changes. + /// e.g. when the store's first refresh lands. + ready_requested: bool, + ready_head: Option, + /// The global checkouts store's ready-to-contribute statuses of this + /// repository, last seen when they drove a render. + /// + /// The store notifies on any recompute pass; the observer re-renders this + /// panel only when these slices changed. + ready_statuses: Vec, + /// The global checkouts store's ready-to-push statuses of this repository, + /// last seen when they drove a render. + push_statuses: Vec, + /// Upstream repository, from this fork's `u` tag, the user asked to open. + /// Its announcement is still being fetched. + pending_upstream: Option, +} + +impl RepoDetailView { + /// Open a repository announced. + /// + /// The store connects to the announcement's relays and loads issues, PRs and statuses. + pub fn new( + dock_area: WeakEntity, + initial: Announcement, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // The announcement we opened from already carries the NIP-34 `relays` tag. + // + // The store connects to those relays immediately, no bootstrap fetch wait. + let addr = initial.addr(); + let relays = initial.relays.clone(); + let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); + + let mut view = Self::new_common( + dock_area, + Some(initial), + Some(store.clone()), + None, + window, + cx, + ); + view.attach_store(&store, cx); + view + } + + /// Open a local repository discovered by the scan. + pub fn new_local( + dock_area: WeakEntity, + local_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new_common(dock_area, None, None, Some(local_path), window, cx) + } + + /// Shared construction. + /// + /// File explorer state, ref selectors and the deferred repository load. + fn new_common( + dock_area: WeakEntity, + initial: Option, + store: Option>, + local_path: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Empty until the clone completes, then filled with the local refs. + let branch_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + + let mut subscriptions = vec![ + cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { + // `Change` fires only when the selection actually changed. + // Picking the already-selected branch emits nothing. + // A confirmed value always means a switch. + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Branch, name.clone(), window, cx); + } + }), + cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Tag, name.clone(), window, cx); + } + }), + ]; + + // The ready-to-contribute and ready-to-push banners are driven by the + // global checkouts store. It notifies on every recompute; compare the + // statuses of this repository so unrelated updates (the sidebar badges, + // other open panels) do not re-render this panel. + let checkouts = CheckoutsStore::global(cx); + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_statuses(cx) { + cx.notify(); + } + })); + + // Defer loading the repository until the window is ready. + cx.defer_in(window, |this, window, cx| { + this.load_repo(window, cx); + }); + + Self { + initial, + dock_area, + store, + local_path, + tree_state, + worktree: None, + worktree_paths: Vec::new(), + md: None, + code: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, + loading_files: HashSet::new(), + commits: HashMap::new(), + pending_commits: Vec::new(), + loading_commits: false, + active_tab: 0, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), + loading: true, + error: None, + head_commit: None, + branch_select, + tag_select, + ref_branches: Vec::new(), + ref_tags: Vec::new(), + switching_ref: false, + ref_generation: 0, + banner_dismissed: HashSet::new(), + ready_requested: false, + ready_head: None, + ready_statuses: Vec::new(), + push_statuses: Vec::new(), + pending_upstream: None, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// The latest announcement from the store or the open-time snapshot. + /// `None` for local repositories that haven't been published yet. + fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { + let store = self.store.as_ref()?; + store + .read(cx) + .announcement + .as_ref() + .or(self.initial.as_ref()) + } + + /// Display name, the announcement's name or ID for announced repositories. + /// The directory name for local ones. + fn display_name(&self, cx: &App) -> SharedString { + if let Some(path) = &self.local_path { + return SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + } + self.announcement(cx) + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() + } +} + +impl BasePanel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo" + } +} + +impl Panel for RepoDetailView { + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.display_name(cx) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let banner = self + .render_ready_banner(cx) + .or_else(|| self.render_push_banner(cx)); + + // View-level load/switch errors, plus the errors of the store-owned + // operations, republish, checkout push, delete and clone-to-folder. + let error = self.error.clone().or_else(|| { + self.store + .as_ref() + .and_then(|store| store.read(cx).last_error.clone().map(SharedString::from)) + }); + + v_flex() + .image_cache(gpui::retain_all("repo")) + .id("repo") + .size_full() + .when_some(banner, |this, banner| this.child(banner)) + .when_some(self.render_push_warning_banner(cx), |this, banner| { + this.child(banner) + }) + .child(self.render_header(cx)) + .when_some(error, |this, error| { + this.child( + Alert::error("repo-error", error) + .banner() + .on_close(cx.listener(|this, _event, _window, cx| { + this.error = None; + if let Some(store) = this.store.clone() { + store.update(cx, |store, _| store.last_error = None); + } + cx.notify(); + })), + ) + }) + .map(|this| match self.active_tab { + 0 => this.child( + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + ), + _ => this.child(self.render_commits_tab(cx)), + }) + } +} diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs new file mode 100644 index 0000000..8983484 --- /dev/null +++ b/crates/workspace/src/views/repo/refs.rs @@ -0,0 +1,285 @@ +use std::collections::HashSet; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{Context, Entity, SharedString, Window}; +use gpui_component::combobox::ComboboxState; +use gpui_component::searchable_list::SearchableVec; + +use super::{RefKind, RepoDetailView}; +use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items}; + +impl RepoDetailView { + /// Check out `name`, a branch or tag picked in the header. + /// Refresh the explorer once the switch completes. + pub(super) fn switch_ref( + &mut self, + kind: RefKind, + name: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if self.switching_ref { + return; + } + let Some(worktree) = self.worktree.clone() else { + return; + }; + + // Branches and tags are mutually exclusive states of HEAD. + // Selecting one clears the other selector. + // Remember the previous selections to restore them if the checkout fails. + let previous_branch = self.branch_select.read(cx).selected_value(); + let previous_tag = self.tag_select.read(cx).selected_value(); + + match kind { + RefKind::Branch => { + self.tag_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + RefKind::Tag => { + self.branch_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + } + self.switching_ref = true; + // In-flight loads of the previous branch are discarded when they complete. + 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.switching_ref = false; + this.restore_selection(&this.branch_select, &previous_branch, window, cx); + this.restore_selection(&this.tag_select, &previous_tag, window, cx); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Restore a selector to `previous`, or clear it after a failed switch. + fn restore_selection( + &self, + select: &Entity>>, + previous: &Option, + window: &mut Window, + cx: &mut Context, + ) { + select.update(cx, |state, cx| match previous { + Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), + None => state.clear_selection(cx), + }); + } + + /// Refresh the file explorer, preview pane and commit list after a successful switch. + /// The selectors were already updated by [`Self::switch_ref`]. + /// [`Self::switching_ref`] stays set until this reload finishes. + /// A second switch cannot interleave. + 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.switching_ref = false; + match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit; + this.worktree_paths = paths; + // Rebuild the tree from scratch. + // Entries of the previous branch are gone. + // The expansion state goes with them. + this.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Drop cached previews and commits of the old branch. + this.selected_file = None; + this.files.clear(); + this.file_order.clear(); + this.preview_bytes = 0; + this.loading_files.clear(); + this.commits.clear(); + this.pending_commits.clear(); + this.loading_commits = false; + this.md = None; + this.code = None; + this.readme_name = None; + this.all_commits = None; + this.loading_all_commits = false; + + if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { + this.readme_name = Some(path.to_string_lossy().into()); + this.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + this.load_all_commits(cx); + } + Err(error) => { + this.error = Some(error.to_string().into()); + this.head_commit = None; + this.worktree_paths.clear(); + // The tree may show files that no longer exist. + this.tree_state.update(cx, |state, cx| { + state.set_items(Vec::new(), cx); + }); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Refresh the file explorer, previews and commit list after the mirror + /// caught up with the remote. + /// + /// The checked-out branch fast-forwarded in place, so unlike + /// [`Self::reload_worktree`] this keeps the panel's selection and previews: + /// it rebuilds the tree, drops previews of files the refresh removed and + /// re-renders the README when it is on screen. + 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); + + // 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 && paths == this.worktree_paths { + log::debug!("repo detail catch_up_worktree: no-op"); + return; + } + + log::debug!( + "repo detail catch_up_worktree: head_changed={head_changed} entries={}", + paths.len() + ); + + this.head_commit = snapshot.head_commit; + this.worktree_paths = paths; + this.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Drop previews of files the refresh removed from the worktree, + // everything else stays put. + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = Vec::new(); + previewed.extend(this.files.keys().cloned()); + previewed.extend(this.selected_file.clone().map(|p| p.to_string())); + + if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { + previewed.push(path.to_string()); + } + + if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { + previewed.push(path.to_string()); + } + + previewed.sort(); + previewed.dedup(); + + for path in previewed { + if !present.contains(&path) { + this.drop_preview_of(&path); + } + } + + // Re-render the README when it is on screen, i.e. when no file preview is open. + if this.selected_file.is_none() { + match snapshot.readme_path.zip(snapshot.readme) { + Some((path, bytes)) => { + this.readme_name = Some(path.to_string_lossy().into()); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + None => { + this.md = None; + this.readme_name = None; + } + } + } + + if head_changed { + this.all_commits = None; + this.loading_all_commits = false; + this.load_all_commits(cx); + } + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } +} diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs new file mode 100644 index 0000000..2bc4e38 --- /dev/null +++ b/crates/workspace/src/views/repo/store.rs @@ -0,0 +1,102 @@ +use gpui::prelude::*; +use gpui::{Context, Entity}; +use signed_core::Announcement; +use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; + +use super::RepoDetailView; + +impl RepoDetailView { + /// Switch the repository into its NIP-34 mode after a successful init. + /// Creates the nostr store for the announced repository. + /// Drops the local scan identity. + /// The worktree is unchanged, so the explorer keeps its loaded content. + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + // The repository is no longer a bare local repo. + // Drop it from the scan results so it leaves the sidebar's local section. + if let Some(path) = self.local_path.take() { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + let store = + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); + // Re-render on store refreshes, issues, PRs and statuses. + // Keep the ready-to-contribute statuses of this repository requested. + self.attach_store(&store, cx); + self.store = Some(store); + self.initial = Some(announcement); + cx.notify(); + } + + /// Observe the repository's store, re-render on refreshes. + /// Request the ready-to-contribute statuses for it. + pub(super) fn attach_store(&mut self, store: &Entity, cx: &mut Context) { + self._subscriptions + .push(cx.observe(store, |this, _store, cx| { + log::debug!("repo detail: store notify"); + this.refresh_ready_statuses(cx); + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + /// Request the statuses of this repository again when the announced HEAD changes. + /// The HEAD is the base the checkouts are compared against. + /// Owned repositories are watched for unpushed commits. + /// Other repositories for ready-to-contribute checkouts. + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(entity) = self.store.clone() else { + return; + }; + + let head = entity.read(cx).head.clone(); + + if self.ready_requested && self.ready_head == head { + return; + } + + self.ready_requested = true; + self.ready_head = head.clone(); + + let addr = entity.read(cx).addr().clone(); + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| entity.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); + } + }); + } + + /// The ready-to-push statuses of this repository in the global checkouts + /// store changed since they last drove a render. + /// + /// Updates the cached slices. `None` store (a local, not yet published, + /// repository) has no statuses. + pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(entity) = self.store.clone() else { + return false; + }; + let addr = entity.read(cx).addr().clone(); + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; + self.ready_statuses = ready_statuses; + self.push_statuses = push_statuses; + changed + } +} diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs deleted file mode 100644 index 49fb6ce..0000000 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ /dev/null @@ -1,160 +0,0 @@ -use gpui::prelude::*; -use gpui::{AnyElement, App, Context, Window, div, px}; -use gpui_component::scroll::Scrollbar; -use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; -use signed_git::FileCommit; -use signed_ui::placeholder; -use utils::relative_time_secs; - -use super::RepoDetailView; - -/// Height of one commit row in the virtual list. -pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; - -pub(super) fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut App) + 'static, - cx: &App, -) -> AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .justify_center() - .child( - h_flex() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_event, window, cx| on_click(window, cx)) - .into_any_element() -} - -impl RepoDetailView { - pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { - let Some(list) = self.all_commits.as_ref() else { - return if self.loading_all_commits { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - } else { - placeholder("Failed to load commits", cx) - }; - }; - - if list.commits.is_empty() { - return placeholder("No commits found", cx); - } - - // Copy only the values the element tree needs. - // The list is borrowed by the renderer below instead of cloned per frame. - // A full history can be tens of thousands of commits. - let view = cx.entity().clone(); - let sizes = self.item_sizes.clone(); - let scroll_handle = self.scroll_handle.clone(); - let shown = list.commits.len(); - let total = list.total; - - v_flex() - .relative() - .flex_1() - .w_full() - .min_h_0() - .child( - v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { - let view = cx.entity().downgrade(); - let commits = this - .all_commits - .as_ref() - .map(|list| list.commits.as_slice()) - .unwrap_or(&[]); - - range - .map(|ix| { - let id = commits[ix].id.clone(); - let view = view.clone(); - - commit_row( - ix, - &commits[ix], - move |window, cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| { - this.open_commit_diff(&id, window, cx) - }); - } - }, - cx, - ) - }) - .collect() - }) - .track_scroll(&scroll_handle) - .size_full(), - ) - .when(shown < total, |this| { - // The history is capped. - // Tell the user the list is truncated. - this.child( - div() - .py_2() - .w_full() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("Showing {shown} of {total} commits")), - ) - }) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_handle)), - ) - .into_any_element() - } -} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs deleted file mode 100644 index 1ff0b63..0000000 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ /dev/null @@ -1,2706 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::{Component, Path, PathBuf}; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Error; -use assets::CustomIconName; -use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; -use gix::Repository; -use gpui::prelude::*; -use gpui::{ - Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, - Window, div, px, relative, size, transparent_white, -}; -use gpui_base::dock::PanelView; -use gpui_base::{Button as BaseButton, Disableable, Popover}; -use gpui_component::alert::Alert; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; -use gpui_component::menu::DropdownMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tree::TreeState; -use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, - VirtualListScrollHandle, h_flex, v_flex, -}; -use nostr::prelude::{EventId, RelayUrl, ToBech32, Url}; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; -use signed_git::{CommitList, FileCommit}; -use signed_state::{ - Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, - RepoListStore, RepoStore, pr_proposes_checkout, -}; -use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; - -mod about; -mod browser; -mod commits; -mod diff; -mod helpers; -mod init_dialog; -mod issue_detail; -mod issues; -mod new_pull_request; -mod pull_request_detail; -mod pull_requests; -mod send_patch; - -use about::open_about_dialog; -use browser::{ - CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, - MarkdownView, -}; -use commits::COMMIT_ROW_HEIGHT; -use diff::CommitDiffView; -use helpers::{ - ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, - tree_items, -}; -use issue_detail::IssueDetailView; -use issues::{IssuesView, open_new_issue_dialog}; -use pull_request_detail::PullRequestDetailView; -use pull_requests::PullRequestsView; -use send_patch::open_send_patch_panel; - -use crate::views::repo_detail::new_pull_request::open_new_pull_panel; - -/// What kind of ref the header selectors switch to. -#[derive(Clone, Copy, PartialEq, Eq)] -enum RefKind { - /// A local branch `refs/heads/*`, HEAD stays attached. - Branch, - /// A tag `refs/tags/*`, HEAD becomes detached. - Tag, -} - -/// Header actions dispatched by the dropdown menus of the header buttons. -/// `pub(super)` because the pull-request list panel shares this action set. -/// It offers the New-PR and Send-patch actions in its own dropdown. -#[derive(Clone, Action, PartialEq, Eq)] -#[action(namespace = repo_detail, no_json)] -pub(super) enum RepoAction { - /// Open the new issue dialog. - NewIssue, - /// Open the new pull request dialog. - NewPR, - /// Open the send patch panel. - SendPatch, - /// Open the about dialog. - About, - /// Re-push the repository to its grasp servers. - Push, - /// Delete the repository from nostr, owner only. - Delete, -} - -/// Everything loaded from the local clone for the explorer. -/// The tree seeds, README, refs and HEAD commit. -/// Computed on a background thread, see [`load_repo_data`]. -/// Applied on the main thread. -struct RepoData { - tree: Vec, - readme_path: Option, - readme: Option>, - worktree: Option, - branches: Vec, - tags: Vec, - current_branch: Option, - head_commit: Option, -} - -/// Detail view of a repository, header, stats and metadata. -/// A file explorer with README preview, cloned from the announcement's `clone` URLs. -pub struct RepoDetailView { - focus_handle: FocusHandle, - /// Dock area the detail view lives in. - /// New panels, commit diffs, are added there. - dock_area: WeakEntity, - /// Snapshot taken at open time. - /// Shown until the store's first refresh completes. - /// Also a fallback while the store has no announcement. - /// `None` for local repositories that haven't been published yet. - initial: Option, - /// Per-repository nostr store, holding announcement, issues, PRs and statuses. - /// `None` until a local repository is initialized to NIP-34. - store: Option>, - /// Path of the local repository when opened from the scan. - /// `None` once it is initialized to NIP-34, or for announced repositories. - local_path: Option, - /// File explorer state, the worktree of the local clone. - tree_state: Entity, - /// Root of the local clone, for reading files on demand. - worktree: Option, - /// Markdown document currently in the preview pane, README or a file. - md: Option, - /// Code file currently in the preview pane. - code: Option, - readme_name: Option, - /// Currently previewed file, a relative path, and its contents. - selected_file: Option, - files: HashMap, - /// Paths of cached previews, oldest first. - /// Feeds the eviction caps in [`Self::evict_previews`]. - file_order: VecDeque, - /// Total text bytes held by [`Self::files`]. - preview_bytes: usize, - /// Reads in flight, to avoid duplicate loads. - loading_files: HashSet, - /// Latest commit touching a previewed file or the README, keyed by path. - commits: HashMap, - /// Paths queued for the next batched commit query, see [`Self::load_commits`]. - pending_commits: Vec, - /// A batched commit query is in flight. - loading_commits: bool, - /// Active header tab, 0 = Files tree, 1 = Commits. - active_tab: usize, - /// Commits reachable from HEAD, newest first. - /// `None` until the walk finishes or fails. - /// [`CommitList`] caps the list, `total` feeds the tab badge. - all_commits: Option, - /// Commit walk in flight. - loading_all_commits: bool, - /// Virtual list state of the Commits tab. - scroll_handle: VirtualListScrollHandle, - item_sizes: Rc>>, - /// A clone/fetch is in flight. - loading: bool, - error: Option, - /// Commit HEAD currently points to, shown in the header button. - head_commit: Option, - /// Branch selector in the header, local branches, searchable. - branch_select: Entity>>, - /// Tag selector in the header, tags, searchable. - tag_select: Entity>>, - /// A branch/tag switch is in flight, checkout plus explorer reload. - switching_ref: bool, - /// Bumped on every branch/tag switch. - /// In-flight loads with an older generation are discarded when they complete. - ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. - _subscriptions: Vec, - /// `(path, branch)` ready-suggestions dismissed by the user, per panel. - banner_dismissed: HashSet<(PathBuf, String)>, - /// The announced HEAD the ready statuses were last requested with. - /// Whether they were requested at all. - /// Re-requested only when the HEAD, the base default, changes. - /// e.g. when the store's first refresh lands. - ready_requested: bool, - ready_head: Option, - /// The global checkouts store's ready-to-contribute statuses of this - /// repository, last seen when they drove a render. - /// - /// The store notifies on any recompute pass; the observer re-renders this - /// panel only when these slices changed. - ready_statuses: Vec, - /// The global checkouts store's ready-to-push statuses of this repository, - /// last seen when they drove a render. - push_statuses: Vec, - /// Upstream repository, from this fork's `u` tag, the user asked to open. - /// Its announcement is still being fetched. - pending_upstream: Option, -} - -impl RepoDetailView { - /// Open a repository announced. - /// - /// The store connects to the announcement's relays and loads issues, PRs and statuses. - pub fn new( - dock_area: WeakEntity, - initial: Announcement, - window: &mut Window, - cx: &mut Context, - ) -> Self { - // The announcement we opened from already carries the NIP-34 `relays` tag. - // The store connects to those relays immediately, no bootstrap fetch wait. - let addr = initial.addr(); - let relays = initial.relays.clone(); - let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - - let mut view = Self::new_common( - dock_area, - Some(initial), - Some(store.clone()), - None, - window, - cx, - ); - view.attach_store(&store, cx); - view - } - - /// Open a local repository discovered by the scan. - pub fn new_local( - dock_area: WeakEntity, - local_path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new_common(dock_area, None, None, Some(local_path), window, cx) - } - - /// Shared construction. - /// - /// File explorer state, ref selectors and the deferred repository load. - fn new_common( - dock_area: WeakEntity, - initial: Option, - store: Option>, - local_path: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); - - // Empty until the clone completes, then filled with the local refs. - let branch_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - let tag_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - - let mut subscriptions = vec![ - cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - // `Change` fires only when the selection actually changed. - // Picking the already-selected branch emits nothing. - // A confirmed value always means a switch. - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Branch, name.clone(), window, cx); - } - }), - cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Tag, name.clone(), window, cx); - } - }), - ]; - - // The ready-to-contribute and ready-to-push banners are driven by the - // global checkouts store. It notifies on every recompute; compare the - // statuses of this repository so unrelated updates (the sidebar badges, - // other open panels) do not re-render this panel. - let checkouts = CheckoutsStore::global(cx); - subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { - if this.refresh_statuses(cx) { - cx.notify(); - } - })); - - // Defer loading the repository until the window is ready. - cx.defer_in(window, |this, window, cx| { - this.load_repo(window, cx); - }); - - Self { - initial, - dock_area, - store, - local_path, - tree_state, - worktree: None, - md: None, - code: None, - readme_name: None, - selected_file: None, - files: HashMap::new(), - file_order: VecDeque::new(), - preview_bytes: 0, - loading_files: HashSet::new(), - commits: HashMap::new(), - pending_commits: Vec::new(), - loading_commits: false, - active_tab: 0, - all_commits: None, - loading_all_commits: false, - scroll_handle: VirtualListScrollHandle::new(), - item_sizes: Rc::new(Vec::new()), - loading: true, - error: None, - head_commit: None, - branch_select, - tag_select, - switching_ref: false, - ref_generation: 0, - banner_dismissed: HashSet::new(), - ready_requested: false, - ready_head: None, - ready_statuses: Vec::new(), - push_statuses: Vec::new(), - pending_upstream: None, - focus_handle: cx.focus_handle(), - _subscriptions: subscriptions, - } - } - - /// Load the repository and populate the file explorer. - /// - /// A local, not yet published, repository opens straight from disk. - /// An announced repository's clone, if any, loads first without touching the network. - fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.error = None; - cx.notify(); - - // Local repositories live on disk at their scan path. - // No clone step or network refresh applies here. - if let Some(local_path) = self.local_path.clone() { - 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(()) - }); - - task.detach(); - - return; - } - - let Some(initial) = self.initial.as_ref() else { - return; - }; - - let cache = GitStore::global(cx).cache().clone(); - let addr = initial.addr(); - let clone_urls: Vec = initial.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(_))); - - // No local clone yet, so clone from the network then load. - 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(); - - this.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - - this.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(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.all_commits.is_none() { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); - } - - if moved { - this.catch_up_worktree(cx); - } - - cx.notify(); - } - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Apply the loaded repository data. - fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { - let RepoData { - tree, - 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); - self.head_commit = head_commit; - self.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Populate the branch/tag selectors with the local refs. - // Select the branch HEAD points to. - let branches: Vec = branches.into_iter().map(Into::into).collect(); - let tags: Vec = tags.into_iter().map(Into::into).collect(); - - self.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - - self.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); - - self.load_all_commits(cx); - - if let Some((path, bytes)) = readme_path.zip(readme) { - self.readme_name = Some(path.to_string_lossy().into()); - self.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - self.set_markdown(None, &text, cx); - } - } - } - - /// Clone the repository into a user-chosen folder outside the cache. - fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - - 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. - // Both are sanitized to a safe single path component. - 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(()) - }); - - task.detach(); - } - - /// Preview the file at `path`, relative to the worktree root. - fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { - self.selected_file = Some(path.into()); - - if self.files.contains_key(path) { - // The file is cached, but the markdown or code state may hold a different file. - // Re-point it at this one, the parse runs on a background task either way. - // Without this, the pane would show a spinner forever. - if let Some(FileContent::Text(text)) = self.files.get(path) { - let text = text.clone(); - if is_markdown_path(path) { - if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { - self.set_markdown(Some(path.into()), &text, cx); - } - } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { - self.set_code(path.into(), &text, window, cx); - } - } - cx.notify(); - return; - } - if self.loading_files.contains(path) { - cx.notify(); - return; - } - - // Paths come from our own tree walk, but never trust them. - // Refuse anything that could escape the worktree. - let rel = Path::new(path); - let unsafe_path = rel.is_absolute() - || rel.components().any(|c| { - matches!( - c, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }); - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - if unsafe_path { - return; - } - - self.loading_files.insert(path.to_string()); - let path = path.to_string(); - - self.load_commit(&path, cx); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let path_for_read = path.clone(); - let content = cx - .background_spawn(async move { - let full = worktree.join(&path_for_read); - // Refuse oversized files before reading them. - // Reading a multi-gigabyte file just to classify it is wasteful. - // It would burn disk and memory bandwidth. - let metadata = match std::fs::metadata(&full) { - Ok(metadata) => metadata, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - if metadata.len() > MAX_PREVIEW_BYTES as u64 { - return Ok(FileContent::TooLarge); - } - let bytes = match std::fs::read(&full) { - Ok(bytes) => bytes, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - match String::from_utf8(bytes) { - Ok(text) => Ok(FileContent::Text(text)), - Err(_) => Ok(FileContent::Binary), - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - // The worktree was switched while this file was reading. - // The result belongs to the previous branch. - // Clear the in-flight marker either way. - // Otherwise the path could never be loaded again. - if generation != this.ref_generation { - this.loading_files.remove(&path); - return; - } - this.loading_files.remove(&path); - match content { - Ok(kind) => { - if let FileContent::Text(text) = &kind { - if is_markdown_path(&path) { - let same = this.md.as_ref().map(|md| md.path.as_deref()) - == Some(Some(path.as_str())); - if !same { - this.set_markdown(Some(path.clone().into()), text, cx); - } - } else { - let same = this.code.as_ref().map(|code| code.path.as_str()) - == Some(path.as_str()); - if !same { - this.set_code(path.clone().into(), text, window, cx); - } - } - this.preview_bytes += text.len(); - } - this.files.insert(path.clone(), kind); - this.file_order.push_back(path); - this.evict_previews(); - } - Err(error) => { - this.files - .insert(path, FileContent::Failed(error.to_string())); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Queue `path` for the per-file commit query. - /// Requests are batched into one history walk, see [`Self::load_commits`]. - fn load_commit(&mut self, path: &str, cx: &mut Context) { - if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { - return; - } - self.pending_commits.push(path.to_string()); - if !self.loading_commits { - self.load_commits(cx); - } - } - - /// Walk history once for every queued path on a background task. - /// Cache the latest commit touching each path in [`Self::commits`]. - /// That feeds the file header in the content column. - /// Batching shares one walk across paths queued while the previous walk ran. - fn load_commits(&mut self, cx: &mut Context) { - if self.pending_commits.is_empty() || self.loading_commits { - return; - } - let Some(worktree) = self.worktree.clone() else { - self.pending_commits.clear(); - return; - }; - - self.loading_commits = true; - let paths = std::mem::take(&mut self.pending_commits); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let rels: Vec = paths.iter().map(PathBuf::from).collect(); - let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) - .await; - - this.update(cx, |this, cx| { - this.loading_commits = false; - if generation == this.ref_generation - && let Ok(found) = result - { - for (path, commit) in found { - this.commits - .insert(path.to_string_lossy().into_owned(), commit); - } - } - // Paths queued while the walk was in flight start the next batch. - // A stale walk, branch switched mid-flight, must not strand them. - // This runs under the current generation regardless of the result. - if !this.pending_commits.is_empty() { - this.load_commits(cx); - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Walk all commits reachable from HEAD on a background task. - /// For the Commits tab and its total-count badge. - /// [`CommitList`] caps the list, only the newest commits are materialized. - fn load_all_commits(&mut self, cx: &mut Context) { - if self.loading_all_commits || self.all_commits.is_some() { - return; - } - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - self.loading_all_commits = true; - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) - .await; - - this.update(cx, |this, cx| { - // A stale walk, branch switched mid-flight, must not leave the flag set. - // Otherwise the Commits tab would spin forever. - if generation != this.ref_generation { - this.loading_all_commits = false; - return; - } - if let Ok(list) = result { - let count = list.commits.len(); - this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.all_commits = Some(list); - } - this.loading_all_commits = false; - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Open a new panel showing the diff of `commit_id`. - fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - // Same display name as the repo detail panel's title. - let repo_name = self.display_name(cx); - - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Re-push the repository's refs to its announced grasp servers. - fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - - self.error = None; - cx.notify(); - - store - .update(cx, |store, cx| store.push_repository(cx)) - .detach(); - } - - /// Push the unpushed commits of the local checkout at `path`. - fn push_unpushed_checkout( - &mut self, - path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let Some(store) = self.store.clone() else { - return; - }; - - if store.read(cx).pushing { - return; - } - - self.error = None; - cx.notify(); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - // The store owns the push, its busy flag and error reporting. - let push = this.update_in(cx, |_this, _window, cx| { - store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) - })?; - - // The remote moved, refresh the mirror browsing. - // Failures already surfaced in the store's error banner. - if let Ok(()) = push.await { - this.update_in(cx, |this, window, cx| { - this.load_repo(window, cx); - })?; - } - - Ok(()) - }); - - task.detach(); - } - - /// Delete the repository from nostr, announcement, state and activity. - fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - store - .update(cx, |store, cx| store.delete_repository(cx)) - .detach(); - } - - /// Open the issues list panel in the dock area. - fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - 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); - }); - } - - /// Open the pull requests list panel in the dock area. - fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - 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 upstream announcement may not be in the local database yet. - /// Subscribe for it and open the panel as soon as it lands. - fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - if self.pending_upstream.is_some() { - return; - } - - let Some(announcement) = self.announcement(cx).cloned() else { - return; - }; - - let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { - return; - }; - - if let Some(found) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned() - { - open_repo_panel(&self.dock_area, &found, window, &mut *cx); - return; - } - - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| { - backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); - }); - self.pending_upstream = Some(addr); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - for _ in 0..60 { - cx.background_executor() - .timer(Duration::from_millis(250)) - .await; - - let opened = this.update_in(cx, |this, window, cx| { - let Some(addr) = this.pending_upstream.clone() else { - return true; - }; - let found = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned(); - match found { - Some(found) => { - this.pending_upstream = None; - open_repo_panel(&this.dock_area, &found, window, &mut *cx); - true - } - None => false, - } - })?; - - if opened { - return Ok(()); - } - } - - this.update(cx, |this, _cx| this.pending_upstream = None)?; - Ok(()) - }); - - task.detach(); - } - - /// Check out `name`, a branch or tag picked in the header. - /// Refresh the explorer once the switch completes. - fn switch_ref( - &mut self, - kind: RefKind, - name: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - if self.switching_ref { - return; - } - let Some(worktree) = self.worktree.clone() else { - return; - }; - - // Branches and tags are mutually exclusive states of HEAD. - // Selecting one clears the other selector. - // Remember the previous selections to restore them if the checkout fails. - let previous_branch = self.branch_select.read(cx).selected_value(); - let previous_tag = self.tag_select.read(cx).selected_value(); - - match kind { - RefKind::Branch => { - self.tag_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - RefKind::Tag => { - self.branch_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - } - self.switching_ref = true; - // In-flight loads of the previous branch are discarded when they complete. - 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.switching_ref = false; - this.restore_selection(&this.branch_select, &previous_branch, window, cx); - this.restore_selection(&this.tag_select, &previous_tag, window, cx); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Restore a selector to `previous`, or clear it after a failed switch. - fn restore_selection( - &self, - select: &Entity>>, - previous: &Option, - window: &mut Window, - cx: &mut Context, - ) { - select.update(cx, |state, cx| match previous { - Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), - None => state.clear_selection(cx), - }); - } - - /// Refresh the file explorer, preview pane and commit list after a successful switch. - /// The selectors were already updated by [`Self::switch_ref`]. - /// [`Self::switching_ref`] stays set until this reload finishes. - /// A second switch cannot interleave. - 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); - Ok::<_, Error>((snapshot, tree)) - }) - .await; - - this.update(cx, |this, cx| { - this.switching_ref = false; - match result { - Ok((snapshot, tree)) => { - this.head_commit = snapshot.head_commit; - // Rebuild the tree from scratch. - // Entries of the previous branch are gone. - // The expansion state goes with them. - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop cached previews and commits of the old branch. - this.selected_file = None; - this.files.clear(); - this.file_order.clear(); - this.preview_bytes = 0; - this.loading_files.clear(); - this.commits.clear(); - this.pending_commits.clear(); - this.loading_commits = false; - this.md = None; - this.code = None; - this.readme_name = None; - this.all_commits = None; - this.loading_all_commits = false; - - if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { - this.readme_name = Some(path.to_string_lossy().into()); - this.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - this.load_all_commits(cx); - } - Err(error) => { - this.error = Some(error.to_string().into()); - this.head_commit = None; - // The tree may show files that no longer exist. - this.tree_state.update(cx, |state, cx| { - state.set_items(Vec::new(), cx); - }); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Refresh the file explorer, previews and commit list after the mirror - /// caught up with the remote. - /// - /// The checked-out branch fast-forwarded in place, so unlike - /// [`Self::reload_worktree`] this keeps the panel's selection and previews: - /// it rebuilds the tree, drops previews of files the refresh removed and - /// re-renders the README when it is on screen. - 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); - Ok::<_, Error>((snapshot, tree)) - }) - .await; - - this.update(cx, |this, cx| { - match result { - Ok((snapshot, tree)) => { - let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) - != this.head_commit.as_ref().map(|c| &c.id); - - this.head_commit = snapshot.head_commit; - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop previews of files the refresh removed from the worktree, - // everything else stays put. - let present: HashSet = snapshot - .entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let mut previewed: Vec = Vec::new(); - previewed.extend(this.files.keys().cloned()); - previewed.extend(this.selected_file.clone().map(|p| p.to_string())); - - if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { - previewed.push(path.to_string()); - } - - if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { - previewed.push(path.to_string()); - } - - previewed.sort(); - previewed.dedup(); - - for path in previewed { - if !present.contains(&path) { - this.drop_preview_of(&path); - } - } - - // Re-render the README when it is on screen, i.e. when no file preview is open. - if this.selected_file.is_none() { - match snapshot.readme_path.zip(snapshot.readme) { - Some((path, bytes)) => { - this.readme_name = Some(path.to_string_lossy().into()); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - None => { - this.md = None; - this.readme_name = None; - } - } - } - - if head_changed { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); - } - } - Err(error) => { - this.error = Some(error.to_string().into()); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Drop the cached preview, editor and commit state of `path`. - fn drop_preview_of(&mut self, path: &str) { - if let Some(FileContent::Text(text)) = self.files.remove(path) { - self.preview_bytes -= text.len(); - } - self.commits.remove(path); - if self.selected_file.as_deref() == Some(path) { - self.selected_file = None; - } - if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { - self.md = None; - } - if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { - self.code = None; - } - } - - /// Drop the oldest previews beyond the cache caps. - /// Keep the currently selected file. - /// An evicted file's parsed editor state drops with its entry. - /// Re-opening it re-parses on a background task. - fn evict_previews(&mut self) { - while (self.files.len() > MAX_PREVIEWED_FILES - || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) - && self.file_order.len() > 1 - { - let path = self.file_order.pop_front().expect("non-empty"); - if Some(path.as_str()) == self.selected_file.as_deref() { - self.file_order.push_back(path); - continue; - } - if let Some(FileContent::Text(text)) = self.files.remove(&path) { - self.preview_bytes -= text.len(); - } - if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { - self.md = None; - } - if self - .code - .as_ref() - .is_some_and(|code| code.path.as_ref() == path.as_str()) - { - self.code = None; - } - self.commits.remove(&path); - } - } - - /// The latest announcement from the store or the open-time snapshot. - /// `None` for local repositories that haven't been published yet. - fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { - let store = self.store.as_ref()?; - store - .read(cx) - .announcement - .as_ref() - .or(self.initial.as_ref()) - } - - /// Display name, the announcement's name or ID for announced repositories. - /// The directory name for local ones. - fn display_name(&self, cx: &App) -> SharedString { - if let Some(path) = &self.local_path { - return SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ); - } - self.announcement(cx) - .map(|announcement| { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default() - } - - /// The NIP-34 header, actions and issues/PR counts. - /// Or the local header with an Init button for an unpublished repository. - fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.local_path.is_some() { - return self.render_local_header(cx); - } - - let Some(store_entity) = self.store.as_ref() else { - return div().into_any_element(); - }; - - let store = store_entity.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().or(self.initial.as_ref()) else { - return div().into_any_element(); - }; - - // Derived NIP-34 header data, share targets and clone commands. - // Rebuilt per frame: two bech32 encodes and a couple of format strings. - 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 = self.display_name(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 => { - if let Some(store) = this.store.clone() { - open_new_issue_dialog(store, window, cx); - } - } - RepoAction::NewPR => { - if let Some(store) = this.store.clone() { - open_new_pull_panel(this.dock_area.clone(), store, window, cx); - } - } - RepoAction::SendPatch => { - if let Some(store) = this.store.clone() { - open_send_patch_panel(this.dock_area.clone(), store, window, cx); - } - } - RepoAction::About => { - 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() - } - - /// Header for a local, not yet published, repository. - /// The directory name and path with an Init button instead of the NIP-34 actions. - fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = self.display_name(cx); - let path = self - .local_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() - } - - /// Open the dialog guiding the user through publishing the local repository to NIP-34. - fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.local_path.clone() else { - return; - }; - let view = cx.entity().downgrade(); - init_dialog::open(local_path, view, window, cx); - } - - /// Switch the repository into its NIP-34 mode after a successful init. - /// Creates the nostr store for the announced repository. - /// Drops the local scan identity. - /// The worktree is unchanged, so the explorer keeps its loaded content. - pub(crate) fn apply_announcement( - &mut self, - announcement: Announcement, - cx: &mut Context, - ) { - // The repository is no longer a bare local repo. - // Drop it from the scan results so it leaves the sidebar's local section. - if let Some(path) = self.local_path.take() { - LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); - } - let store = - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render on store refreshes, issues, PRs and statuses. - // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, cx); - self.store = Some(store); - self.initial = Some(announcement); - cx.notify(); - } - - /// Observe the repository's store, re-render on refreshes. - /// Request the ready-to-contribute statuses for it. - fn attach_store(&mut self, store: &Entity, cx: &mut Context) { - self._subscriptions - .push(cx.observe(store, |this, _store, cx| { - this.refresh_ready_statuses(cx); - cx.notify(); - })); - self.refresh_ready_statuses(cx); - } - - /// Request the statuses of this repository again when the announced HEAD changes. - /// The HEAD is the base the checkouts are compared against. - /// Owned repositories are watched for unpushed commits. - /// Other repositories for ready-to-contribute checkouts. - fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(entity) = self.store.clone() else { - return; - }; - - let head = entity.read(cx).head.clone(); - - if self.ready_requested && self.ready_head == head { - return; - } - - self.ready_requested = true; - self.ready_head = head.clone(); - - let addr = entity.read(cx).addr().clone(); - let backend = Backend::global(cx); - let checkout = CheckoutsStore::global(cx); - - let owned = backend - .read(cx) - .current_user() - .is_some_and(|user| entity.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); - } - }); - } - - /// The ready-to-push statuses of this repository in the global checkouts - /// store changed since they last drove a render. - /// - /// Updates the cached slices. `None` store (a local, not yet published, - /// repository) has no statuses. - fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(entity) = self.store.clone() else { - return false; - }; - let addr = entity.read(cx).addr().clone(); - let checkouts = CheckoutsStore::global(cx).read(cx); - let ready_statuses = checkouts.ready_statuses_of(&addr); - let push_statuses = checkouts.push_statuses_of(&addr); - - let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; - self.ready_statuses = ready_statuses; - self.push_statuses = push_statuses; - changed - } - - /// The first checkout ready for a pull request on this repository. - /// Not covered by an open PR of the signed-in user. - /// Not dismissed in this panel. - /// The repository's own checkouts are not suggested here. - /// Their work is pushed, see [`Self::push_suggestion`]. - fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.as_ref()?; - let addr = store.read(cx).addr().clone(); - let user = Backend::global(cx).read(cx).current_user()?; - if store.read(cx).is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); - - 'status: for status in statuses { - if self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - { - continue; - } - let store = store.read(cx); - 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 entity = self.store.as_ref()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if !entity.read(cx).is_author(&user) { - return None; - } - - let addr = entity.read(cx).addr().clone(); - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); - - statuses.into_iter().find(|status| { - !self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - }) - } - - /// The ready-to-push banner of an owned repository. - /// - /// A local checkout has unpushed commits, with a Push action and a dismiss control. - fn render_push_banner(&self, cx: &Context) -> Option { - let status = self.push_suggestion(cx)?; - let key = (status.path.clone(), status.branch.clone()); - let path = status.path.clone(); - // The push busy flag lives on the store; it disables the banner's triggers. - let pushing = self - .store - .as_ref() - .is_some_and(|store| 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(status.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.banner_dismissed.insert(key.clone()); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// Warning after a push that only some grasp servers accepted. - fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.as_ref()?; - let store = 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| { - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| { - store.last_push_warning = None; - }); - } - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// The ready-to-contribute banner of the repository panel. - fn render_ready_banner(&self, cx: &Context) -> Option { - let status = self.ready_suggestion(cx)?; - let key = (status.path.clone(), status.branch.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(status.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(status.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| { - if let Some(store) = this.store.clone() { - open_new_pull_panel( - this.dock_area.clone(), - store, - 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.banner_dismissed.insert(key.clone()); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// The tab row shared by both header variants. - /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. - fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.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.open_commit_diff(&id, window, cx); - } - })), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.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.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() - } -} - -impl BasePanel for RepoDetailView { - fn panel_name(&self) -> &'static str { - "repo_detail" - } -} - -impl Panel for RepoDetailView { - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.display_name(cx) - } -} - -impl EventEmitter for RepoDetailView {} - -impl Focusable for RepoDetailView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RepoDetailView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - - let banner = self - .render_ready_banner(cx) - .or_else(|| self.render_push_banner(cx)); - - // View-level load/switch errors, plus the errors of the store-owned - // operations, republish, checkout push, delete and clone-to-folder. - let error = self.error.clone().or_else(|| { - self.store - .as_ref() - .and_then(|store| store.read(cx).last_error.clone().map(SharedString::from)) - }); - - v_flex() - .image_cache(gpui::retain_all("repo")) - .id("repo") - .size_full() - .when_some(banner, |this, banner| this.child(banner)) - .when_some(self.render_push_warning_banner(cx), |this, banner| { - this.child(banner) - }) - .child(self.render_header(cx)) - .when_some(error, |this, error| { - this.child( - Alert::error("repo-error", error) - .banner() - .on_close(cx.listener(|this, _event, _window, cx| { - this.error = None; - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| store.last_error = None); - } - cx.notify(); - })), - ) - }) - .map(|this| match self.active_tab { - 0 => this.child( - h_flex() - .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - ), - _ => this.child(self.render_commits_tab(cx)), - }) - } -} - -/// Read the worktree state of `repo`, no network. -/// -/// Entries, README, refs and HEAD commit. -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI. - // A broken ref must not prevent the explorer from loading. - // Failures degrade to empty selectors. - let (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), - }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - Ok(RepoData { - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} - -/// The `nostr://...` clone URL of an announcement, NIP-34. -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) -} - -/// The forked-from row of the detail header. -/// -/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. -/// Plain text when it only carries a git 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() - }) -} - -/// Open `announcement` as a repository panel in the dock's center. -pub(crate) fn open_repo_panel( - dock_area: &WeakEntity, - announcement: &Announcement, - window: &mut Window, - cx: &mut App, -) -> Entity { - let detail = - cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); - - if let Some(dock_area) = dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); - }); - } - - detail -} - -/// The nostr store of `announcement`'s repository, without opening a repository panel. -fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) -} - -/// An item of a repository to open from outside its detail panel. -/// A patch has no detail view in Signed, so it opens nothing. -pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch, -} - -/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. -/// -/// The repository store is built here, not taken from a `RepoDetailView`, so the -/// item panel is the only panel docked. -/// -/// A patch opens nothing: patches are only consumed inside a pull request's -/// detail panel, and have no panel of their own. -pub(crate) fn open_repo_item( - dock_area: &WeakEntity, - announcement: &Announcement, - item: RepoItem, - window: &mut Window, - cx: &mut App, -) { - let panel: Arc = - match item { - RepoItem::Issue(issue_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) - } - RepoItem::PullRequest(pr_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| { - PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) - })) - } - RepoItem::Patch => return, - }; - - let Some(dock_area) = dock_area.upgrade() else { - return; - }; - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel, window, cx); - }); -} diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/send_patch.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/send_patch.rs rename to crates/workspace/src/views/send_patch.rs